From f81f80fde44792a1f4b6e81d9c649e471b53ad77 Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Mon, 9 Feb 2026 11:24:12 -0800 Subject: [PATCH 01/16] feat(whatsapp): add WhatsApp channel support Add WhatsApp as a second messaging channel via the whatsapp-rust crate, gated behind the `whatsapp` cargo feature (enabled by default). New crate `moltis-whatsapp` with: - QR code pairing through WhatsApp Linked Devices - Sled-backed persistent Signal Protocol store (survives restarts) - Inbound text, media (image/voice/video/document/location) handling - Outbound text replies with typing indicators - DM and group access control (Open/Allowlist/Disabled) with OTP flow - Self-chat support with watermark-based loop prevention Multi-channel architecture refactor: - LiveChannelService now supports concrete plugin fields behind feature flags - MultiChannelOutbound routes outbound messages by account type - ChannelPlugin trait extended for WhatsApp lifecycle Gateway integration: - Channel REST/RPC routes for WhatsApp config and pairing - Real-time pairing events over WebSocket - Chat routing for WhatsApp inbound messages - Web UI: channel type picker, QR code modal, WhatsApp card/edit views Documentation and changelog updated. --- CHANGELOG.md | 41 + Cargo.lock | 866 +++++++++++++++++- Cargo.toml | 15 +- README.md | 7 +- crates/channels/src/plugin.rs | 92 +- crates/cli/Cargo.toml | 3 +- crates/config/src/schema.rs | 3 + crates/config/src/validate.rs | 5 +- crates/gateway/Cargo.toml | 4 + crates/gateway/src/assets/js/page-channels.js | 410 +++++++-- crates/gateway/src/assets/style.css | 2 +- crates/gateway/src/channel.rs | 630 ++++++++++--- crates/gateway/src/channel_events.rs | 15 +- crates/gateway/src/chat.rs | 59 +- crates/gateway/src/server.rs | 114 ++- crates/whatsapp/Cargo.toml | 39 + crates/whatsapp/src/access.rs | 230 +++++ crates/whatsapp/src/config.rs | 158 ++++ crates/whatsapp/src/connection.rs | 142 +++ crates/whatsapp/src/handlers.rs | 854 +++++++++++++++++ crates/whatsapp/src/lib.rs | 17 + crates/whatsapp/src/memory_store.rs | 570 ++++++++++++ crates/whatsapp/src/otp.rs | 371 ++++++++ crates/whatsapp/src/outbound.rs | 93 ++ crates/whatsapp/src/plugin.rs | 261 ++++++ crates/whatsapp/src/sled_store.rs | 741 +++++++++++++++ crates/whatsapp/src/state.rs | 200 ++++ docs/src/SUMMARY.md | 1 + docs/src/whatsapp.md | 381 ++++++++ 29 files changed, 6025 insertions(+), 299 deletions(-) create mode 100644 crates/whatsapp/Cargo.toml create mode 100644 crates/whatsapp/src/access.rs create mode 100644 crates/whatsapp/src/config.rs create mode 100644 crates/whatsapp/src/connection.rs create mode 100644 crates/whatsapp/src/handlers.rs create mode 100644 crates/whatsapp/src/lib.rs create mode 100644 crates/whatsapp/src/memory_store.rs create mode 100644 crates/whatsapp/src/otp.rs create mode 100644 crates/whatsapp/src/outbound.rs create mode 100644 crates/whatsapp/src/plugin.rs create mode 100644 crates/whatsapp/src/sled_store.rs create mode 100644 crates/whatsapp/src/state.rs create mode 100644 docs/src/whatsapp.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ca1235f0..3601e88b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **WhatsApp channel support**: Add WhatsApp as a second messaging channel via + the `whatsapp-rust` crate. Supports QR code pairing through WhatsApp Linked + Devices, inbound text/slash-command handling, outbound text replies, typing + indicators, and real-time pairing events over WebSocket. Gated behind the + `whatsapp` cargo feature (enabled by default). +- **Multi-channel architecture**: Refactored `LiveChannelService` from + Telegram-only to a multi-channel registry with concrete plugin fields behind + feature flags. Added `MultiChannelOutbound` for routing outbound messages by + account type. +- **WhatsApp Web UI**: Channel type picker, QR code pairing modal, and + WhatsApp-specific channel card/edit views in the Channels settings page. +- **New crate `moltis-whatsapp`**: Pure Rust WhatsApp client plugin with + in-memory Signal Protocol store, connection lifecycle management, event + handlers, and outbound adapter. +- **WhatsApp session persistence**: Replace in-memory store with sled-backed + persistent storage so Signal Protocol state (keys, sessions, device info) + survives restarts. One sled database per account at + `~/.moltis/whatsapp//`. No more re-scanning QR codes after + each restart. +- **WhatsApp access control**: DM and group access policies (Open, Allowlist, + Disabled), per-account allowlists, and OTP self-approval flow — matching + the Telegram channel's access control model. Includes gateway integration + for sender approve/deny and web UI for configuring policies. +- **WhatsApp media handling**: Support for inbound image, voice/audio, video, + document, and location messages. Images are downloaded and optimized for + LLM consumption via `moltis-media`. Voice messages are transcribed via STT + when available. Video thumbnails are sent as image attachments. Documents + dispatch caption and metadata. Locations resolve pending tool requests or + dispatch coordinates to the LLM. +- **WhatsApp self-chat support**: Automatically detect and process messages sent + to yourself via WhatsApp's "Message Yourself" feature, without requiring a + separate phone number. Self-chat messages bypass access control (the account + owner is always authorized). Dual loop prevention: sent-message-ID tracking + in a bounded ring buffer, plus an invisible Unicode watermark (ZWJ/ZWNJ + sequence) appended to all bot-sent messages as a secondary check. +- **WhatsApp documentation**: Added `docs/src/whatsapp.md` covering setup (CLI + and Web UI), configuration reference, access control, session persistence, + self-chat, media handling, and troubleshooting. + ## [0.3.7] - 2026-02-09 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index f4ab4ba9..9538c244 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -135,6 +170,12 @@ dependencies = [ "password-hash", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "arrayvec" version = "0.7.6" @@ -201,6 +242,18 @@ dependencies = [ "futures-core", ] +[[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-compression" version = "0.4.37" @@ -213,6 +266,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite", +] + [[package]] name = "async-openai" version = "0.32.4" @@ -560,6 +624,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -613,6 +686,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2698f953def977c68f935bb0dfa959375ad4638570e969e2f1e9f433cbf1af6" +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.55" @@ -739,10 +821,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" dependencies = [ "chrono", - "phf", + "phf 0.12.1", "serde", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -902,6 +994,24 @@ dependencies = [ "version_check", ] +[[package]] +name = "cookie_store" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fc4bff745c9b4c7fb1e97b25d13153da2bc7796260141df62378998d070207f" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1039,6 +1149,15 @@ version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b10589d1a5e400d61f9f38f12f884cfd080ff345de8f17efda36fe0e4a02aa8" +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "curl" version = "0.4.49" @@ -1070,6 +1189,33 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "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.114", +] + [[package]] name = "darling" version = "0.13.4" @@ -1186,7 +1332,7 @@ dependencies = [ "hashbrown 0.14.5", "lock_api", "once_cell", - "parking_lot_core", + "parking_lot_core 0.9.12", ] [[package]] @@ -1442,6 +1588,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -1576,12 +1731,31 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", +] + [[package]] name = "env_home" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "env_filter", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1636,6 +1810,16 @@ dependencies = [ "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 5.4.1", + "pin-project-lite", +] + [[package]] name = "eventsource-stream" version = "0.2.3" @@ -1702,6 +1886,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "file-id" version = "0.2.3" @@ -1737,6 +1927,12 @@ dependencies = [ "glob", ] +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -1745,6 +1941,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs 0.6.0", ] [[package]] @@ -1810,6 +2007,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1875,7 +2082,7 @@ checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" dependencies = [ "futures-core", "lock_api", - "parking_lot", + "parking_lot 0.12.5", ] [[package]] @@ -1959,6 +2166,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "genai" version = "0.5.3" @@ -2019,6 +2235,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gix" version = "0.78.0" @@ -2073,7 +2299,7 @@ dependencies = [ "gix-worktree", "gix-worktree-state", "gix-worktree-stream", - "parking_lot", + "parking_lot 0.12.5", "regex", "signal-hook", "smallvec", @@ -2335,11 +2561,11 @@ dependencies = [ "gix-utils", "libc", "once_cell", - "parking_lot", + "parking_lot 0.12.5", "prodash", "thiserror 2.0.18", "walkdir", - "zlib-rs", + "zlib-rs 0.5.5", ] [[package]] @@ -2409,7 +2635,7 @@ checksum = "52f1eecdd006390cbed81f105417dbf82a6fe40842022006550f2e32484101da" dependencies = [ "gix-hash", "hashbrown 0.16.1", - "parking_lot", + "parking_lot 0.12.5", ] [[package]] @@ -2528,7 +2754,7 @@ dependencies = [ "gix-pack", "gix-path", "gix-quote", - "parking_lot", + "parking_lot 0.12.5", "tempfile", "thiserror 2.0.18", ] @@ -2600,7 +2826,7 @@ checksum = "6d48536da48fa4ae9d99bf46479f37a19a58427711e1927c80790856d4a490f6" dependencies = [ "gix-command", "gix-config-value", - "parking_lot", + "parking_lot 0.12.5", "rustix", "thiserror 2.0.18", ] @@ -2777,7 +3003,7 @@ dependencies = [ "dashmap", "gix-fs", "libc", - "parking_lot", + "parking_lot 0.12.5", "signal-hook", "signal-hook-registry", "tempfile", @@ -2904,7 +3130,7 @@ dependencies = [ "gix-object", "gix-path", "gix-traverse", - "parking_lot", + "parking_lot 0.12.5", "thiserror 2.0.18", ] @@ -3545,6 +3771,16 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "instant" version = "0.1.13" @@ -3611,7 +3847,7 @@ version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "334e04b4d781f436dc315cb1e7515bd96826426345d498149e4bde36b67f8ee9" dependencies = [ - "async-channel", + "async-channel 1.9.0", "castaway", "crossbeam-utils", "curl", @@ -3650,6 +3886,15 @@ dependencies = [ "either", ] +[[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.17" @@ -3895,6 +4140,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "llama-cpp-2" version = "0.1.133" @@ -3986,6 +4237,12 @@ dependencies = [ "digest", ] +[[package]] +name = "md5" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" + [[package]] name = "memchr" version = "2.8.0" @@ -4138,6 +4395,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moka" +version = "0.12.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" +dependencies = [ + "async-lock", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "event-listener 5.4.1", + "futures-util", + "parking_lot 0.12.5", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "moltis" version = "0.3.7" @@ -4366,10 +4643,12 @@ dependencies = [ "moltis-telegram", "moltis-tools", "moltis-voice", + "moltis-whatsapp", "openssl", "p256", "password-hash", "pulldown-cmark", + "qrcode", "rand 0.9.2", "rcgen", "reqwest 0.12.28", @@ -4698,6 +4977,34 @@ dependencies = [ "wiremock", ] +[[package]] +name = "moltis-whatsapp" +version = "0.3.7" +dependencies = [ + "anyhow", + "async-trait", + "dashmap", + "moltis-channels", + "moltis-common", + "moltis-config", + "moltis-media", + "moltis-metrics", + "rand 0.9.2", + "serde", + "serde_json", + "sled", + "tempfile", + "tokio", + "tokio-util", + "tracing", + "wacore", + "wacore-binary", + "waproto", + "whatsapp-rust", + "whatsapp-rust-tokio-transport", + "whatsapp-rust-ureq-http-client", +] + [[package]] name = "moxcms" version = "0.7.11" @@ -4708,6 +5015,12 @@ dependencies = [ "pxfm", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "native-tls" version = "0.2.14" @@ -4904,6 +5217,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.3.3" @@ -5020,6 +5339,17 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.6", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -5027,7 +5357,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", - "parking_lot_core", + "parking_lot_core 0.9.12", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall 0.2.16", + "smallvec", + "winapi", ] [[package]] @@ -5066,6 +5410,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "pem" version = "0.8.3" @@ -5112,13 +5466,53 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] -name = "phf" -version = "0.12.1" +name = "petgraph" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ - "phf_shared", -] + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.13.0", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand 2.3.0", + "phf_shared 0.13.1", +] [[package]] name = "phf_shared" @@ -5129,6 +5523,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.10" @@ -5239,6 +5642,18 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -5338,7 +5753,76 @@ checksum = "962200e2d7d551451297d9fdce85138374019ada198e30ea9ede38034e27604c" dependencies = [ "bytesize", "human_format", - "parking_lot", + "parking_lot 0.12.5", +] + +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +dependencies = [ + "heck 0.5.0", + "itertools 0.14.0", + "log", + "multimap", + "petgraph", + "prost", + "prost-types", + "regex", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost", +] + +[[package]] +name = "protobuf" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-support" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" +dependencies = [ + "thiserror 1.0.69", ] [[package]] @@ -5368,6 +5852,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" +dependencies = [ + "image", +] + [[package]] name = "quanta" version = "0.12.6" @@ -5569,6 +6062,15 @@ dependencies = [ "yasna", ] +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -6162,6 +6664,15 @@ dependencies = [ "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_cbor_2" version = "0.13.0" @@ -6402,6 +6913,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[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.2" @@ -6426,13 +6943,29 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "sled" +version = "0.34.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f96b4737c2ce5987354855aed3797279def4ebf734436c6aa4552cf8e169935" +dependencies = [ + "crc32fast", + "crossbeam-epoch", + "crossbeam-utils", + "fs2", + "fxhash", + "libc", + "log", + "parking_lot 0.11.2", +] + [[package]] name = "sluice" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d7400c0eff44aa2fcb5e31a5f24ba9716ed90138769e4977a2ba6014ae63eb5" dependencies = [ - "async-channel", + "async-channel 1.9.0", "futures-core", "futures-io", ] @@ -6839,6 +7372,12 @@ dependencies = [ "libc", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "take_mut" version = "0.2.2" @@ -7057,7 +7596,7 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", + "parking_lot 0.12.5", "pin-project-lite", "signal-hook-registry", "socket2 0.6.2", @@ -7155,6 +7694,27 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-websockets" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6aa6c8b5a31e06fd3760eb5c1b8d9072e30731f0467ee3795617fe768e7449" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-sink", + "http 1.4.0", + "httparse", + "rand 0.9.2", + "ring", + "rustls-pki-types", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + [[package]] name = "toml" version = "0.8.23" @@ -7440,6 +8000,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -7452,6 +8022,37 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc97a28575b85cfedf2a7e7d3cc64b3e11bd8ac766666318003abbacc7a21fc" +dependencies = [ + "base64 0.22.1", + "cookie_store", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf-8", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" +dependencies = [ + "base64 0.22.1", + "http 1.4.0", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -7530,6 +8131,110 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wacore" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c38f75041655201a01725a43d99da2f258de6e9e17033b167f99d458070c4f1" +dependencies = [ + "aes", + "aes-gcm", + "anyhow", + "async-channel 2.5.0", + "async-trait", + "base64 0.22.1", + "bytes", + "chrono", + "ctr", + "flate2", + "hex", + "hkdf", + "hmac", + "log", + "md5", + "once_cell", + "pbkdf2", + "prost", + "protobuf", + "rand 0.9.2", + "rand_core 0.9.5", + "serde", + "serde-big-array", + "sha2", + "thiserror 2.0.18", + "wacore-appstate", + "wacore-binary", + "wacore-libsignal", + "waproto", +] + +[[package]] +name = "wacore-appstate" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8128197fd310dbc350cf7d417666d31591a84fa2d42b3b5692e48da9e0500ea" +dependencies = [ + "anyhow", + "hkdf", + "prost", + "serde", + "serde-big-array", + "serde_json", + "sha2", + "thiserror 2.0.18", + "wacore-binary", + "wacore-libsignal", + "waproto", +] + +[[package]] +name = "wacore-binary" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d560076c8483f9197ca0e2c46e98e3cb01fc30c089114704b851b3f73b6b94d6" +dependencies = [ + "flate2", + "indexmap 2.13.0", + "phf 0.13.1", + "phf_codegen", + "serde", + "serde_json", +] + +[[package]] +name = "wacore-libsignal" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02751fcbb54d2abb19b61261217e444f06889fdd85b8f035b2a420d7636a92ee" +dependencies = [ + "aes", + "aes-gcm", + "arrayref", + "async-trait", + "cbc", + "chrono", + "ctr", + "curve25519-dalek", + "derive_more 2.1.1", + "displaydoc", + "ghash", + "hex", + "hkdf", + "hmac", + "itertools 0.14.0", + "log", + "prost", + "rand 0.9.2", + "serde", + "sha1", + "sha2", + "subtle", + "thiserror 2.0.18", + "uuid", + "waproto", + "x25519-dalek", +] + [[package]] name = "waker-fn" version = "1.2.0" @@ -7555,6 +8260,17 @@ dependencies = [ "try-lock", ] +[[package]] +name = "waproto" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0fac064a4b1d339d7343612c0544739118e23969464f3e215eae2813e4f65d0" +dependencies = [ + "prost", + "prost-build", + "serde", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -7789,6 +8505,82 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whatsapp-rust" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af27e10258392b3d8accee86ec80815673815e07ea5fc53897eba73aacb372b6" +dependencies = [ + "anyhow", + "async-channel 2.5.0", + "async-trait", + "base64 0.22.1", + "bytes", + "chrono", + "dashmap", + "env_logger", + "hex", + "indexmap 2.13.0", + "log", + "moka", + "prost", + "rand 0.9.2", + "rand_core 0.9.5", + "scopeguard", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "wacore", + "wacore-binary", + "waproto", + "whatsapp-rust-tokio-transport", + "whatsapp-rust-ureq-http-client", +] + +[[package]] +name = "whatsapp-rust-tokio-transport" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da5151ff392012ef834b8820785d03e44b78f6139e60a3b02614bfbc477d17a" +dependencies = [ + "anyhow", + "async-channel 2.5.0", + "async-trait", + "bytes", + "futures-util", + "http 1.4.0", + "log", + "rustls", + "tokio", + "tokio-rustls", + "tokio-websockets", + "wacore", + "webpki-roots", +] + +[[package]] +name = "whatsapp-rust-ureq-http-client" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063d5d6d08711de6cade6903cefeebd708070e5979c984ac025d0271f655b97f" +dependencies = [ + "anyhow", + "async-trait", + "tokio", + "ureq", + "wacore", +] + [[package]] name = "which" version = "7.0.3" @@ -8368,6 +9160,18 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "x509-parser" version = "0.16.0" @@ -8473,6 +9277,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] [[package]] name = "zerotrie" @@ -8513,6 +9331,12 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" +[[package]] +name = "zlib-rs" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7948af682ccbc3342b6e9420e8c51c1fe5d7bf7756002b4a3c6cabfe96a7e3c" + [[package]] name = "zmij" version = "1.0.19" diff --git a/Cargo.toml b/Cargo.toml index a6360ece..0a0de936 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ members = [ "crates/telegram", "crates/tools", "crates/voice", + "crates/whatsapp", ] resolver = "2" @@ -94,8 +95,19 @@ open = "5.3" rand = "0.9" secrecy = { features = ["serde"], version = "0.8" } sha2 = "0.10" +sled = "0.34" sysinfo = "0.34" teloxide = { features = ["macros"], version = "0.13" } +# WhatsApp (sqlite-storage disabled to avoid libsqlite3-sys conflict with sqlx; +# re-enable once sqlx 0.9 stabilises). +wacore = "0.2" +wacore-binary = "0.2" +waproto = "0.2" +whatsapp-rust = { default-features = false, features = ["tokio-native", "tokio-transport", "ureq-client"], version = "0.2" } +whatsapp-rust-tokio-transport = "0.2" +whatsapp-rust-ureq-http-client = "0.2" +# QR code rendering +qrcode = "0.14" # TLS / certificate generation axum-server = { features = ["tls-rustls"], version = "0.7" } rcgen = "0.13" @@ -126,7 +138,7 @@ image = { default-features = false, features = ["jpeg", "png", "webp"], pulldown-cmark = { default-features = false, version = "0.12" } # Filesystem / archiving chrono = "0.4" -chrono-tz = { version = "0.10", features = ["serde"] } +chrono-tz = { features = ["serde"], version = "0.10" } cron = "0.13" dirs-next = "2" flate2 = "1" @@ -163,3 +175,4 @@ moltis-skills = { path = "crates/skills" } moltis-telegram = { path = "crates/telegram" } moltis-tools = { path = "crates/tools" } moltis-voice = { path = "crates/voice" } +moltis-whatsapp = { path = "crates/whatsapp" } diff --git a/README.md b/README.md index 55adc0bb..2acfdb3b 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,8 @@ cargo install moltis --git https://github.com/moltis-org/moltis - **Streaming responses** — real-time token streaming for a responsive user experience, including when tools are enabled (tool calls stream argument deltas as they arrive) -- **Communication channels** — Telegram integration with an extensible channel - abstraction for adding others +- **Communication channels** — Telegram and WhatsApp integration with an + extensible multi-channel architecture - **Web gateway** — HTTP and WebSocket server with a built-in web UI - **Session persistence** — SQLite-backed conversation history, session management, and per-session run serialization to prevent history corruption @@ -207,7 +207,7 @@ cloud relay required. ``` ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ Web UI │ │ Telegram │ │ Discord │ +│ Web UI │ │ Telegram │ │ WhatsApp │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ └────────┬───────┴────────┬───────┘ @@ -477,6 +477,7 @@ Moltis is organized as a Cargo workspace with the following crates: | `moltis-agents` | LLM provider integrations | | `moltis-channels` | Communication channel abstraction | | `moltis-telegram` | Telegram integration | +| `moltis-whatsapp` | WhatsApp integration | | `moltis-config` | Configuration management | | `moltis-sessions` | Session persistence | | `moltis-memory` | Embeddings-based knowledge base | diff --git a/crates/channels/src/plugin.rs b/crates/channels/src/plugin.rs index 8698db4b..9d99a3e8 100644 --- a/crates/channels/src/plugin.rs +++ b/crates/channels/src/plugin.rs @@ -9,7 +9,7 @@ use { #[serde(rename_all = "lowercase")] pub enum ChannelType { Telegram, - // Future: Discord, Slack, WhatsApp, etc. + Whatsapp, } impl ChannelType { @@ -17,6 +17,15 @@ impl ChannelType { pub fn as_str(&self) -> &'static str { match self { Self::Telegram => "telegram", + Self::Whatsapp => "whatsapp", + } + } + + /// Human-readable display name for UI labels. + pub fn display_name(&self) -> &'static str { + match self { + Self::Telegram => "Telegram", + Self::Whatsapp => "WhatsApp", } } } @@ -33,6 +42,7 @@ impl std::str::FromStr for ChannelType { fn from_str(s: &str) -> std::result::Result { match s { "telegram" => Ok(Self::Telegram), + "whatsapp" => Ok(Self::Whatsapp), other => Err(format!("unknown channel type: {other}")), } } @@ -77,6 +87,26 @@ pub enum ChannelEvent { username: Option, resolution: String, }, + /// A QR code was generated for device pairing (e.g. WhatsApp Linked Devices). + PairingQrCode { + channel_type: ChannelType, + account_id: String, + /// Raw QR data string to be rendered as a QR code image. + qr_data: String, + }, + /// Device pairing completed successfully. + PairingComplete { + channel_type: ChannelType, + account_id: String, + /// Display name of the paired device/account. + display_name: Option, + }, + /// Device pairing failed. + PairingFailed { + channel_type: ChannelType, + account_id: String, + reason: String, + }, } /// Sink for channel events — the gateway provides the concrete implementation. @@ -363,4 +393,64 @@ mod tests { }; assert!(!sink.update_location(&target, 48.8566, 2.3522).await); } + + #[test] + fn channel_type_whatsapp_roundtrip() { + let ct = ChannelType::Whatsapp; + assert_eq!(ct.as_str(), "whatsapp"); + assert_eq!(ct.to_string(), "whatsapp"); + assert_eq!("whatsapp".parse::().unwrap(), ct); + } + + #[test] + fn channel_type_serde_roundtrip() { + for ct in [ChannelType::Telegram, ChannelType::Whatsapp] { + let json = serde_json::to_string(&ct).unwrap(); + let parsed: ChannelType = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, ct); + } + } + + #[test] + fn channel_type_from_str_unknown_errors() { + assert!("discord".parse::().is_err()); + } + + #[test] + fn pairing_qr_code_event_serialization() { + let event = ChannelEvent::PairingQrCode { + channel_type: ChannelType::Whatsapp, + account_id: "wa1".into(), + qr_data: "2@abc123".into(), + }; + let json = serde_json::to_value(&event).unwrap(); + assert_eq!(json["kind"], "pairing_qr_code"); + assert_eq!(json["channel_type"], "whatsapp"); + assert_eq!(json["account_id"], "wa1"); + assert_eq!(json["qr_data"], "2@abc123"); + } + + #[test] + fn pairing_complete_event_serialization() { + let event = ChannelEvent::PairingComplete { + channel_type: ChannelType::Whatsapp, + account_id: "wa1".into(), + display_name: Some("My Phone".into()), + }; + let json = serde_json::to_value(&event).unwrap(); + assert_eq!(json["kind"], "pairing_complete"); + assert_eq!(json["display_name"], "My Phone"); + } + + #[test] + fn pairing_failed_event_serialization() { + let event = ChannelEvent::PairingFailed { + channel_type: ChannelType::Whatsapp, + account_id: "wa1".into(), + reason: "timeout".into(), + }; + let json = serde_json::to_value(&event).unwrap(); + assert_eq!(json["kind"], "pairing_failed"); + assert_eq!(json["reason"], "timeout"); + } } diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 0963f633..322c5bf0 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -61,11 +61,12 @@ tracing-subscriber = { workspace = true } tempfile = { workspace = true } [features] -default = ["push-notifications", "tailscale", "tls", "voice"] +default = ["push-notifications", "tailscale", "tls", "voice", "whatsapp"] push-notifications = ["moltis-gateway/push-notifications"] tailscale = ["moltis-gateway/tailscale"] tls = ["moltis-gateway/tls"] voice = ["moltis-gateway/voice"] +whatsapp = ["moltis-gateway/whatsapp"] [lints] workspace = true diff --git a/crates/config/src/schema.rs b/crates/config/src/schema.rs index 7c1ba532..666db103 100644 --- a/crates/config/src/schema.rs +++ b/crates/config/src/schema.rs @@ -948,6 +948,9 @@ pub struct ChannelsConfig { /// Telegram bot accounts, keyed by account ID. #[serde(default)] pub telegram: HashMap, + /// WhatsApp linked-device accounts, keyed by account ID. + #[serde(default)] + pub whatsapp: HashMap, } /// TLS configuration for the gateway HTTPS server. diff --git a/crates/config/src/validate.rs b/crates/config/src/validate.rs index 14b303b6..66fee59c 100644 --- a/crates/config/src/validate.rs +++ b/crates/config/src/validate.rs @@ -305,7 +305,10 @@ fn build_schema_map() -> KnownKeys { ), ( "channels", - Struct(HashMap::from([("telegram", Map(Box::new(Leaf)))])), + Struct(HashMap::from([ + ("telegram", Map(Box::new(Leaf))), + ("whatsapp", Map(Box::new(Leaf))), + ])), ), ( "tls", diff --git a/crates/gateway/Cargo.toml b/crates/gateway/Cargo.toml index 3542c2e5..36b58292 100644 --- a/crates/gateway/Cargo.toml +++ b/crates/gateway/Cargo.toml @@ -42,9 +42,11 @@ moltis-skills = { workspace = true } moltis-telegram = { workspace = true } moltis-tools = { workspace = true } moltis-voice = { optional = true, workspace = true } +moltis-whatsapp = { optional = true, workspace = true } p256 = { features = ["ecdsa"], optional = true, workspace = true } password-hash = { workspace = true } pulldown-cmark = { features = ["html"], workspace = true } +qrcode = { optional = true, workspace = true } rand = { workspace = true } rcgen = { optional = true, workspace = true } reqwest = { workspace = true } @@ -87,6 +89,7 @@ default = [ "tls", "voice", "web-ui", + "whatsapp", ] file-watcher = ["moltis-memory/file-watcher", "moltis-skills/file-watcher"] local-embeddings = ["moltis-memory/local-embeddings"] @@ -108,6 +111,7 @@ tls = [ ] voice = ["dep:moltis-voice"] web-ui = ["dep:include_dir"] +whatsapp = ["dep:moltis-whatsapp", "dep:qrcode"] [dev-dependencies] reqwest = { workspace = true } diff --git a/crates/gateway/src/assets/js/page-channels.js b/crates/gateway/src/assets/js/page-channels.js index d9e38990..19534c7e 100644 --- a/crates/gateway/src/assets/js/page-channels.js +++ b/crates/gateway/src/assets/js/page-channels.js @@ -3,7 +3,7 @@ import { signal, useSignal } from "@preact/signals"; import { html } from "htm/preact"; import { render } from "preact"; -import { useEffect } from "preact/hooks"; +import { useEffect, useRef } from "preact/hooks"; import { onEvent } from "./events.js"; import { sendRpc } from "./helpers.js"; import { updateNavCount } from "./nav-counts.js"; @@ -25,10 +25,17 @@ export function prefetchChannels() { } var senders = signal([]); var activeTab = signal("channels"); -var showAddModal = signal(false); +var showAddTelegramModal = signal(false); +var showAddWhatsAppModal = signal(false); +var showChannelPicker = signal(false); var editingChannel = signal(null); var sendersAccount = signal(""); +// Track WhatsApp pairing state (updated by WebSocket events). +var waQrData = signal(null); +var waPairingAccountId = signal(null); +var waPairingError = signal(null); + function loadChannels() { sendRpc("channels.status", {}).then((res) => { if (res?.ok) { @@ -51,7 +58,7 @@ function loadSenders() { }); } -// ── Telegram icon (inline SVG via htm) ────────────────────── +// ── Channel icons (inline SVG via htm) ─────────────────────── function TelegramIcon() { return html` @@ -59,6 +66,24 @@ function TelegramIcon() { `; } +function WhatsAppIcon() { + return html` + + + `; +} + +function channelIcon(type) { + if (type === "whatsapp") return html`<${WhatsAppIcon} />`; + return html`<${TelegramIcon} />`; +} + +function channelDisplayType(type) { + if (type === "whatsapp") return "WhatsApp"; + return "Telegram"; +} + // ── Channel card ───────────────────────────────────────────── function ChannelCard(props) { var ch = props.channel; @@ -72,7 +97,7 @@ function ChannelCard(props) { }); } - var statusClass = ch.status === "connected" ? "configured" : "oauth"; + var statusClass = ch.status === "connected" ? "configured" : ch.status === "pairing" ? "oauth" : "oauth"; var sessionLine = ""; if (ch.sessions && ch.sessions.length > 0) { var active = ch.sessions.filter((s) => s.active); @@ -85,10 +110,10 @@ function ChannelCard(props) { return html`
- <${TelegramIcon} /> + ${channelIcon(ch.type)}
- ${ch.name || ch.account_id || "Telegram"} + ${ch.name || ch.account_id || channelDisplayType(ch.type)} ${ch.details && html`${ch.details}`} ${sessionLine && html`${sessionLine}`}
@@ -109,13 +134,35 @@ function ChannelCard(props) { function ChannelsTab() { if (channels.value.length === 0) { return html`
-
No Telegram bots connected.
-
Click "+ Add Telegram Bot" to connect one using a token from @BotFather.
+
No channels connected.
+
Click "+ Add Channel" to connect a Telegram bot or WhatsApp account.
`; } return html`${channels.value.map((ch) => html`<${ChannelCard} key=${ch.account_id} channel=${ch} />`)}`; } +// ── Sender row renderer ───────────────────────────────────── +function renderSenderRow(s, onAction) { + var identifier = s.username || s.peer_id; + var lastSeenMs = s.last_seen ? s.last_seen * 1000 : 0; + var statusBadge = s.otp_pending + ? html` { + navigator.clipboard.writeText(s.otp_pending.code).then(() => showToast("OTP code copied")); + }}>OTP: ${s.otp_pending.code}` + : html`${s.allowed ? "Allowed" : "Denied"}`; + var actionBtn = s.allowed + ? html`` + : html``; + return html` + ${s.sender_name || s.peer_id} + ${s.username ? `@${s.username}` : "\u2014"} + ${s.message_count} + ${lastSeenMs ? html`` : "\u2014"} + ${statusBadge} + ${actionBtn} + `; +} + // ── Senders tab ────────────────────────────────────────────── function SendersTab() { useEffect(() => { @@ -165,32 +212,7 @@ function SendersTab() { StatusAction - ${senders.value.map((s) => { - var identifier = s.username || s.peer_id; - var lastSeenMs = s.last_seen ? s.last_seen * 1000 : 0; - return html` - ${s.sender_name || s.peer_id} - ${s.username ? `@${s.username}` : "\u2014"} - ${s.message_count} - ${lastSeenMs ? html`` : "\u2014"} - - ${ - s.otp_pending - ? html` { - navigator.clipboard.writeText(s.otp_pending.code).then(() => showToast("OTP code copied")); - }}>OTP: ${s.otp_pending.code}` - : html`${s.allowed ? "Allowed" : "Denied"}` - } - - - ${ - s.allowed - ? html`` - : html`` - } - - `; - })} + ${senders.value.map((s) => renderSenderRow(s, onAction))} ` } @@ -246,8 +268,45 @@ function AllowlistInput({ value, onChange }) {
`; } -// ── Add channel modal ──────────────────────────────────────── -function AddChannelModal() { +// ── Channel type picker (dropdown) ─────────────────────────── +function ChannelPicker() { + var ref = useRef(null); + useEffect(() => { + if (!showChannelPicker.value) return; + function onClickOutside(e) { + if (ref.current && !ref.current.contains(e.target)) { + showChannelPicker.value = false; + } + } + document.addEventListener("click", onClickOutside, true); + return () => document.removeEventListener("click", onClickOutside, true); + }, [showChannelPicker.value]); + + if (!showChannelPicker.value) return null; + + return html`
+ + +
`; +} + +// ── Add Telegram modal ─────────────────────────────────────── +function AddTelegramModal() { var error = useSignal(""); var saving = useSignal(false); var addModel = useSignal(""); @@ -286,7 +345,7 @@ function AddChannelModal() { }).then((res) => { saving.value = false; if (res?.ok) { - showAddModal.value = false; + showAddTelegramModal.value = false; addModel.value = ""; allowlistItems.value = []; loadChannels(); @@ -306,8 +365,8 @@ function AddChannelModal() { var inputStyle = "font-family:var(--font-body);background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:8px 12px;font-size:.85rem;"; - return html`<${Modal} show=${showAddModal.value} onClose=${() => { - showAddModal.value = false; + return html`<${Modal} show=${showAddTelegramModal.value} onClose=${() => { + showAddTelegramModal.value = false; }} title="Add Telegram Bot">
@@ -352,6 +411,144 @@ function AddChannelModal() { `; } +// ── QR code SVG renderer ───────────────────────────────────── +// Renders a QR data string as a simple SVG using the canvas-free +// approach: each module is a element. +function QrCodeDisplay({ data }) { + if (!data) + return html`
Waiting for QR code...
`; + + // Generate a simple visual representation. Since we don't have + // a full QR encoder in JS, we display the raw data with a prompt + // to scan from the terminal, plus auto-refresh via WebSocket. + return html`
+
+
+
${data.substring(0, 200)}
+
+
+
+ Scan this QR code in your terminal output,
or open WhatsApp > Settings > Linked Devices > Link a Device. +
+
`; +} + +// ── Add WhatsApp modal ─────────────────────────────────────── +function AddWhatsAppModal() { + var error = useSignal(""); + var saving = useSignal(false); + var addModel = useSignal(""); + var pairingStarted = useSignal(false); + var allowlistItems = useSignal([]); + + var inputStyle = + "font-family:var(--font-body);background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:8px 12px;font-size:.85rem;"; + var selectStyle = + "font-family:var(--font-body);background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:8px 12px;font-size:.85rem;cursor:pointer;"; + + function onStartPairing(e) { + e.preventDefault(); + var form = e.target.closest(".channel-form"); + var accountId = form.querySelector("[data-field=accountId]").value.trim(); + if (!accountId) { + error.value = "Account ID is required."; + return; + } + error.value = ""; + saving.value = true; + waQrData.value = null; + waPairingError.value = null; + waPairingAccountId.value = accountId; + + var addConfig = { + dm_policy: form.querySelector("[data-field=dmPolicy]")?.value || "open", + allowlist: allowlistItems.value, + }; + if (addModel.value) { + addConfig.model = addModel.value; + var found = modelsSig.value.find((x) => x.id === addModel.value); + if (found?.provider) addConfig.model_provider = found.provider; + } + sendRpc("channels.add", { + type: "whatsapp", + account_id: accountId, + config: addConfig, + }).then((res) => { + saving.value = false; + if (res?.ok) { + pairingStarted.value = true; + } else { + error.value = (res?.error && (res.error.message || res.error.detail)) || "Failed to start pairing."; + } + }); + } + + function onClose() { + showAddWhatsAppModal.value = false; + pairingStarted.value = false; + waQrData.value = null; + waPairingError.value = null; + waPairingAccountId.value = null; + allowlistItems.value = []; + loadChannels(); + } + + var defaultPlaceholder = + modelsSig.value.length > 0 + ? `(default: ${modelsSig.value[0].displayName || modelsSig.value[0].id})` + : "(server default)"; + + return html`<${Modal} show=${showAddWhatsAppModal.value} onClose=${onClose} title="Add WhatsApp"> +
+ ${ + pairingStarted.value + ? html` +
+ ${ + waPairingError.value + ? html`
${waPairingError.value}
` + : html`<${QrCodeDisplay} data=${waQrData.value} />` + } +
QR code refreshes automatically. Keep this window open.
+
+ ` + : html` +
+ Link your WhatsApp +
1. Choose an account ID below (any name you like)
+
2. Click "Start Pairing" to generate a QR code
+
3. Open WhatsApp on your phone > Settings > Linked Devices > Link a Device
+
4. Scan the QR code to connect
+
+ + + + + + <${ModelSelect} models=${modelsSig.value} value=${addModel.value} + onChange=${(v) => { + addModel.value = v; + }} + placeholder=${defaultPlaceholder} /> + + <${AllowlistInput} value=${allowlistItems.value} onChange=${(v) => { + allowlistItems.value = v; + }} /> + ${error.value && html`
${error.value}
`} + + ` + } +
+ `; +} + // ── Edit channel modal ─────────────────────────────────────── function EditChannelModal() { var ch = editingChannel.value; @@ -365,18 +562,25 @@ function EditChannelModal() { }, [ch]); if (!ch) return null; var cfg = ch.config || {}; + var isTelegram = ch.type === "telegram"; + var isWhatsApp = ch.type === "whatsapp"; + var hasAccessControl = isTelegram || isWhatsApp; + var title = isTelegram ? "Edit Telegram Bot" : "Edit WhatsApp"; function onSave(e) { e.preventDefault(); var form = e.target.closest(".channel-form"); error.value = ""; saving.value = true; - var updateConfig = { - token: cfg.token || "", - dm_policy: form.querySelector("[data-field=dmPolicy]").value, - mention_mode: form.querySelector("[data-field=mentionMode]").value, - allowlist: allowlistItems.value, - }; + var updateConfig = {}; + if (isTelegram) { + updateConfig.token = cfg.token || ""; + updateConfig.mention_mode = form.querySelector("[data-field=mentionMode]")?.value || "mention"; + } + if (hasAccessControl) { + updateConfig.dm_policy = form.querySelector("[data-field=dmPolicy]")?.value || "open"; + updateConfig.allowlist = allowlistItems.value; + } if (editModel.value) { updateConfig.model = editModel.value; var found = modelsSig.value.find((x) => x.id === editModel.value); @@ -391,7 +595,7 @@ function EditChannelModal() { editingChannel.value = null; loadChannels(); } else { - error.value = (res?.error && (res.error.message || res.error.detail)) || "Failed to update bot."; + error.value = (res?.error && (res.error.message || res.error.detail)) || "Failed to update channel."; } }); } @@ -406,31 +610,46 @@ function EditChannelModal() { return html`<${Modal} show=${true} onClose=${() => { editingChannel.value = null; - }} title="Edit Telegram Bot"> + }} title=${title}>
${ch.name || ch.account_id}
- - - - + ${ + hasAccessControl && + html` + + + ` + } + ${ + isTelegram && + html` + + + ` + } <${ModelSelect} models=${modelsSig.value} value=${editModel.value} onChange=${(v) => { editModel.value = v; }} placeholder=${defaultPlaceholder} /> - - <${AllowlistInput} value=${allowlistItems.value} onChange=${(v) => { - allowlistItems.value = v; - }} /> + ${ + hasAccessControl && + html` + + <${AllowlistInput} value=${allowlistItems.value} onChange=${(v) => { + allowlistItems.value = v; + }} /> + ` + } ${error.value && html`
${error.value}
`} +
+ + <${ChannelPicker} /> +
` }
${activeTab.value === "channels" ? html`<${ChannelsTab} />` : html`<${SendersTab} />`}
- <${AddChannelModal} /> + <${AddTelegramModal} /> + <${AddWhatsAppModal} /> <${EditChannelModal} /> <${ConfirmDialog} /> `; @@ -504,7 +750,9 @@ registerPage( function initChannels(container) { container.style.cssText = "flex-direction:column;padding:0;overflow:hidden;"; activeTab.value = "channels"; - showAddModal.value = false; + showAddTelegramModal.value = false; + showAddWhatsAppModal.value = false; + showChannelPicker.value = false; editingChannel.value = null; sendersAccount.value = ""; senders.value = []; diff --git a/crates/gateway/src/assets/style.css b/crates/gateway/src/assets/style.css index 759fff07..2ae9503c 100644 --- a/crates/gateway/src/assets/style.css +++ b/crates/gateway/src/assets/style.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-500:oklch(63.7% .237 25.331);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-gray-500:oklch(55.1% .027 264.364);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-3xl:48rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height:calc(1.5/1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--tracking-wide:.025em;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--shadow-sm:0 1px 3px 0 #0000001a,0 1px 2px -1px #0000001a;--shadow-md:0 4px 6px -1px #0000001a,0 2px 4px -2px #0000001a;--shadow-lg:0 10px 15px -3px #0000001a,0 4px 6px -4px #0000001a;--ease-out:cubic-bezier(0,0,.2,1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0,0,.2,1)infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*,:before,:after{box-sizing:border-box;margin:0;padding:0}html,body{height:100%;font-family:var(--font-body);background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}body{flex-direction:column;display:flex}::selection{background:var(--accent-subtle);color:var(--text-strong)}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:0 0}::-webkit-scrollbar-thumb{background:var(--border);border-radius:9999px}::-webkit-scrollbar-thumb:hover{background:var(--border-strong)}}@layer components{.status-dot{background:var(--muted);border-radius:50%;flex-shrink:0;width:8px;height:8px;transition:background .3s}.status-dot.connected{background:var(--ok)}.status-dot.connecting{background:var(--warn);animation:1s infinite pulse}.msg{border-radius:var(--radius);word-wrap:break-word;white-space:pre-wrap;max-width:80%;padding:10px 14px;font-size:.9rem;line-height:1.6;animation:.2s ease-out msg-in}.msg.user{background:var(--user-bg);border:1px solid var(--user-border);color:var(--text);align-self:flex-end}.queued-tray{border-top:1px dashed var(--border);background:var(--surface);flex-direction:column;gap:6px;padding:8px 16px;display:flex}.msg.user.queued{opacity:.6;border-style:dashed;align-self:flex-end;max-width:100%}.queued-badge{color:var(--muted);align-items:center;gap:6px;margin-top:6px;font-size:.75rem;display:flex}.queued-label{font-style:italic}.queued-cancel{border:1px solid var(--border);color:var(--muted);cursor:pointer;background:0 0;border-radius:4px;padding:1px 6px;font-size:.7rem;transition:color .15s,border-color .15s}.queued-cancel:hover{color:var(--error);border-color:var(--error)}.msg.assistant{background:var(--assistant-bg);border:1px solid var(--assistant-border);color:var(--text);align-self:flex-start}.msg.system{color:var(--muted);background:0 0;align-self:center;padding:4px 0;font-size:.8rem}.msg.error{color:var(--error);background:0 0;align-self:center;padding:4px 0;font-size:.8rem}.msg.error-card{background:var(--surface);border:1px solid var(--error);border-radius:8px;align-self:center;align-items:flex-start;gap:10px;max-width:420px;padding:12px 16px;display:flex}.error-icon{flex-shrink:0;font-size:1.4rem;line-height:1}.error-body{flex:1;min-width:0}.error-title{color:var(--error);margin-bottom:2px;font-size:.85rem;font-weight:600}.error-detail{color:var(--fg);opacity:.85;font-size:.8rem}.error-countdown{color:var(--muted);white-space:nowrap;flex-shrink:0;align-self:center;font-size:.75rem}.error-countdown.reset-ready{color:var(--success,#22c55e);font-weight:600}.msg.thinking{background:var(--assistant-bg);border:1px solid var(--assistant-border);align-items:center;padding:14px 18px;display:flex}.thinking-dots{align-items:center;gap:5px;display:flex}.thinking-dots span{background:var(--muted);border-radius:50%;width:7px;height:7px;animation:1.2s ease-in-out infinite dot-bounce}.thinking-dots span:nth-child(2){animation-delay:.15s}.thinking-dots span:nth-child(3){animation-delay:.3s}.thinking-text{color:var(--muted);white-space:pre-wrap;text-overflow:ellipsis;-webkit-line-clamp:2;-webkit-box-orient:vertical;font-size:.82rem;font-style:italic;line-height:1.4;animation:2s ease-in-out infinite thinking-pulse;display:-webkit-box;overflow:hidden}@keyframes thinking-pulse{0%,to{opacity:.6}50%{opacity:1}}.msg.tool-event{font-family:var(--font-mono);font-size:.78rem}.msg.tool-ok{color:var(--ok)}.msg.tool-err{color:var(--error)}.msg.exec-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);white-space:normal;flex-direction:column;align-self:flex-start;gap:4px;max-width:70%;padding:8px 12px;display:flex}.msg.exec-card.exec-ok{border-color:var(--border)}.msg.exec-card.exec-err{border-color:var(--error)}.exec-prompt{font-family:var(--font-mono);color:var(--text);font-size:.8rem;line-height:1.4}.exec-prompt-char{color:var(--ok);-webkit-user-select:none;user-select:none;font-weight:600}.exec-card.exec-err .exec-prompt-char{color:var(--error)}.exec-status{color:var(--muted);font-size:.72rem;font-style:italic}.exec-output{font-family:var(--font-mono);color:var(--text);background:var(--bg);border:1px solid var(--border);white-space:pre;border-radius:4px;max-height:200px;margin:0;padding:6px 8px;font-size:.78rem;line-height:1.45;overflow:auto}.exec-output.exec-stderr{color:var(--warn)}.exec-exit{font-family:var(--font-mono);color:var(--error);font-size:.72rem}.exec-error-detail{color:var(--error);font-size:.78rem}.exec-card.exec-retry{opacity:.6;border-left-color:var(--muted)}.exec-retry-detail{color:var(--muted);font-size:.72rem;font-style:italic}.screenshot-container{margin-top:8px;position:relative}.screenshot-thumbnail{border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;max-width:200px;max-height:150px;transition:border-color .15s,transform .15s}.screenshot-thumbnail:hover{border-color:var(--accent);transform:scale(1.02)}.screenshot-lightbox{z-index:9999;cursor:zoom-out;background:#000000e6;justify-content:center;align-items:stretch;padding:20px;display:flex;position:fixed;inset:0}.screenshot-lightbox-content{cursor:default;flex-direction:column;align-items:center;gap:12px;width:100%;max-width:100%;max-height:100%;display:flex}.screenshot-lightbox-header{flex-shrink:0;align-items:center;gap:12px;padding:8px 0;display:flex}.screenshot-lightbox-close{color:#fff;cursor:pointer;background:#ffffff26;border:none;border-radius:50%;justify-content:center;align-items:center;width:40px;height:40px;font-size:1.25rem;font-weight:300;transition:background .15s;display:flex}.screenshot-lightbox-close:hover{background:#ffffff40}.screenshot-lightbox-scroll{border-radius:var(--radius-md);scrollbar-width:thin;scrollbar-color:var(--border-strong)transparent;flex:1;justify-content:center;align-items:flex-start;width:100%;min-height:0;display:flex;overflow:auto}.screenshot-lightbox-scroll::-webkit-scrollbar{width:8px}.screenshot-lightbox-scroll::-webkit-scrollbar-track{background:0 0}.screenshot-lightbox-scroll::-webkit-scrollbar-thumb{background:var(--border-strong);border-radius:4px}.screenshot-lightbox-img{border-radius:var(--radius-md);flex-shrink:0;display:block;box-shadow:0 4px 20px #00000080}.screenshot-download-btn{background:var(--accent-dim);color:#fff;border-radius:var(--radius-sm);cursor:pointer;border:none;padding:8px 16px;font-size:.85rem;font-weight:500;transition:background .15s}.screenshot-download-btn:hover{background:var(--accent)}.screenshot-download-btn-small{background:var(--surface);color:var(--text);border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;opacity:0;justify-content:center;align-items:center;width:24px;height:24px;font-size:.7rem;transition:opacity .15s,background .15s;display:flex;position:absolute;top:4px;right:4px}.screenshot-container:hover .screenshot-download-btn-small{opacity:1}.screenshot-download-btn-small:hover{background:var(--accent-dim);color:#fff;border-color:var(--accent-dim)}.msg code{background:var(--surface2);font-family:var(--font-mono);border-radius:4px;padding:1px 6px;font-size:.85em}.msg pre{background:var(--surface);border-radius:var(--radius-sm);border:1px solid var(--border);margin:6px 0;padding:10px 12px;overflow-x:auto}.msg pre code{background:0 0;padding:0}.msg strong{color:var(--text-strong);font-weight:600}.msg-channel-footer{color:var(--muted);margin-top:4px;font-size:.7rem}.theme-toggle{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);align-items:center;gap:2px;padding:2px;display:flex}.theme-btn{width:28px;height:28px;color:var(--muted);cursor:pointer;background:0 0;border:none;border-radius:4px;justify-content:center;align-items:center;transition:all .15s;display:flex}.theme-btn:hover{color:var(--text);background:var(--bg-hover)}.theme-btn.active{color:var(--text-strong);background:var(--bg-elevated);box-shadow:var(--shadow-sm)}.theme-btn svg{width:16px;height:16px}.approval-card{background:var(--surface2);border:1px solid var(--warn);border-radius:var(--radius);flex-direction:column;align-self:center;gap:8px;max-width:90%;padding:12px 16px;display:flex}.approval-label{color:var(--warn);font-size:.8rem;font-weight:600}.approval-cmd{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);font-family:var(--font-mono);color:var(--text);white-space:pre-wrap;word-break:break-all;padding:6px 10px;font-size:.82rem;display:block}.approval-btns{gap:8px;display:flex}.approval-btn{border-radius:var(--radius-sm);cursor:pointer;border:none;padding:5px 14px;font-size:.8rem;font-weight:500;transition:opacity .15s}.approval-btn:disabled{opacity:.4;cursor:default}.approval-allow{background:var(--ok);color:#fff}.approval-deny{background:var(--error);color:#fff}.approval-countdown{color:var(--muted);text-align:right;font-size:.72rem}.approval-status{color:var(--muted);font-size:.78rem;font-style:italic}.approval-resolved{opacity:.6}.approval-expired{opacity:.4}.provider-modal-backdrop{z-index:50;background:#00000080;justify-content:center;align-items:center;display:flex;position:fixed;inset:0}.provider-modal-backdrop.hidden{display:none}.provider-modal{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);flex-direction:column;width:420px;max-width:90vw;max-height:80vh;animation:.15s ease-out msg-in;display:flex}.provider-modal-header{border-bottom:1px solid var(--border);justify-content:space-between;align-items:center;padding:14px 18px;display:flex}.provider-modal-body{flex-direction:column;gap:8px;padding:16px 18px;display:flex;overflow-y:auto}.provider-item{border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;justify-content:space-between;align-items:center;padding:10px 12px;transition:border-color .15s,background .15s;display:flex}.provider-item:hover{border-color:var(--border-strong);background:var(--bg-hover)}.provider-item.configured{opacity:.6}.provider-item-name{color:var(--text);font-size:.85rem;font-weight:500}.provider-item-badge{border-radius:9999px;padding:2px 6px;font-size:.7rem;font-weight:500}.provider-item-badge.api-key{background:var(--accent-subtle);color:var(--accent)}.provider-item-badge.oauth{color:#818cf8;background:#6366f11f}.provider-item-badge.configured{background:var(--accent-subtle);color:var(--ok)}.provider-key-form{flex-direction:column;gap:10px;display:flex}.provider-key-input{background:var(--surface2);border:1px solid var(--border);width:100%;color:var(--text);border-radius:var(--radius-sm);font-size:.8rem;font-family:var(--font-mono);padding:8px 10px}.provider-key-input:focus{border-color:var(--border-strong);outline:none}.provider-btn{border-radius:var(--radius-sm);cursor:pointer;color:#fff;background:var(--accent-dim);border:none;padding:8px 16px;font-size:.8rem;font-weight:500;transition:opacity .15s}.provider-btn:hover{background:var(--accent)}.provider-btn:disabled{opacity:.4;cursor:default}.provider-btn-secondary{background:var(--surface2);color:var(--text);border:1px solid var(--border)}.provider-btn-secondary:hover{background:var(--bg-hover)}.provider-status{color:var(--ok);text-align:center;padding:8px 0;font-size:.8rem}.voice-recording-hint{border-radius:var(--radius-sm);border-left:3px solid var(--error);color:var(--text);background:#ef44441a;align-items:center;gap:8px;margin-top:6px;padding:6px 10px;font-size:.75rem;display:flex}.voice-recording-dot{background:var(--error);border-radius:50%;width:8px;height:8px;animation:1s ease-in-out infinite pulse}@keyframes pulse{0%,to{opacity:1}50%{opacity:.4}}.voice-transcription-result{background:var(--accent-subtle);border-radius:var(--radius-sm);border-left:3px solid var(--accent);align-items:baseline;gap:6px;margin-top:6px;padding:8px 10px;display:flex}.voice-success-result{background:var(--accent-subtle);border-radius:var(--radius-sm);border-left:3px solid var(--accent);color:var(--accent);align-items:center;gap:6px;margin-top:6px;padding:6px 10px;font-size:.75rem;display:flex}.voice-transcription-label{color:var(--accent);text-transform:uppercase;letter-spacing:.02em;flex-shrink:0;font-size:.7rem;font-weight:500}.voice-transcription-text{color:var(--text);font-size:.8rem;font-style:italic}.msg.voice-transcribing{background:var(--user-bg);border:1px solid var(--user-border);border-radius:var(--radius);align-self:flex-end;align-items:center;gap:10px;padding:10px 14px;animation:.15s ease-out msg-in;display:flex}.voice-transcribing-spinner{border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;width:16px;height:16px;animation:.8s linear infinite spin}@keyframes spin{to{transform:rotate(360deg)}}.voice-transcribing-text{color:var(--muted);font-size:.85rem}.nav-panel{border-right:1px solid var(--border);background:var(--surface);flex-direction:column;flex-shrink:0;width:180px;display:flex}.nav-panel.hidden{display:none}.nav-link{color:var(--muted);padding:8px 16px;font-size:.85rem;text-decoration:none;transition:background .15s,color .15s;display:block}.nav-link:hover{background:var(--bg-hover);color:var(--text)}.nav-link.active{color:var(--accent);background:var(--accent-subtle)}.model-combo{position:relative}.model-combo.hidden{display:none}.model-combo-btn{background:var(--surface2);border:1px solid var(--border);color:var(--muted);border-radius:var(--radius-sm);font-size:.75rem;font-family:var(--font-body);cursor:pointer;white-space:nowrap;text-overflow:ellipsis;align-items:center;gap:4px;max-width:220px;padding:4px 8px;transition:border-color .15s,color .15s;display:flex;overflow:hidden}.model-combo-btn:hover,.model-combo-btn:focus{border-color:var(--border-strong);color:var(--text);outline:none}.model-combo-chevron{opacity:.6;flex-shrink:0}.model-dropdown{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);width:280px;box-shadow:var(--shadow-md);z-index:40;flex-direction:column;animation:.1s ease-out msg-in;display:flex;position:absolute;top:calc(100% + 4px);left:0}.model-dropdown.hidden{display:none}.model-search-input{background:var(--surface2);border:none;border-bottom:1px solid var(--border);width:100%;color:var(--text);font-size:.78rem;font-family:var(--font-body);outline:none;padding:8px 10px}.model-search-input::placeholder{color:var(--muted)}.model-dropdown-list{max-height:240px;padding:4px 0;overflow-y:auto}.model-dropdown-item{cursor:pointer;justify-content:space-between;align-items:center;padding:6px 10px;transition:background .1s;display:flex}.model-dropdown-item:hover,.model-dropdown-item.kb-active{background:var(--bg-hover)}.model-dropdown-item.selected{color:var(--accent)}.model-item-label{white-space:nowrap;text-overflow:ellipsis;min-width:0;font-size:.78rem;overflow:hidden}.model-item-provider{color:var(--muted);flex-shrink:0;margin-left:8px;font-size:.68rem}.model-dropdown-empty{color:var(--muted);padding:8px 10px;font-size:.78rem}.session-item{cursor:pointer;border-bottom:1px solid var(--border);justify-content:space-between;align-items:center;padding:8px 12px;transition:background .15s;display:flex}.session-item:hover{background:var(--bg-hover)}.session-item.active{background:var(--accent-subtle);border-left:3px solid var(--accent)}.session-info{flex:1;min-width:0}.session-label{color:var(--text);white-space:nowrap;align-items:center;gap:4px;min-width:0;font-size:.82rem;display:flex}.session-label [data-label-text]{text-overflow:ellipsis;overflow:hidden}.session-meta{color:var(--muted);margin-top:2px;font-size:.7rem}.session-actions{opacity:0;flex-shrink:0;gap:4px;transition:opacity .15s;display:flex}.session-item:hover .session-actions{opacity:1}.session-action-btn{color:var(--muted);cursor:pointer;background:0 0;border:none;border-radius:3px;padding:2px 4px;font-size:.75rem;transition:color .15s,background .15s}.session-action-btn:hover{color:var(--text);background:var(--surface2)}.session-fork-btn{color:var(--muted);cursor:pointer;background:0 0;border:none;border-radius:3px;flex-shrink:0;align-items:center;padding:2px 4px;transition:color .15s,background .15s;display:inline-flex}.session-fork-btn svg{flex-shrink:0;min-width:14px;min-height:14px}.session-fork-btn:hover{color:var(--text);background:var(--surface2)}.session-delete:hover{color:var(--error)}.session-icon{flex-shrink:0;justify-content:center;align-items:center;width:16px;height:16px;display:inline-flex}.session-spinner{color:var(--warn);-webkit-user-select:none;user-select:none;font-family:monospace;font-size:.85rem;line-height:16px;display:none}.session-item.replying .session-icon>svg{display:none}.session-item.replying .session-spinner{display:inline}.session-item.unread .session-label:before{content:"";background:var(--accent);vertical-align:middle;border-radius:50%;width:7px;height:7px;margin-right:6px;display:inline-block}.token-bar{color:var(--muted);text-align:center;border-top:1px solid var(--border);background:var(--surface);padding:2px 12px;font-size:.7rem}.token-bar:empty{display:none}.msg-model-footer{color:var(--muted);text-align:right;margin-top:4px;font-size:.68rem}.cron-status-bar{color:var(--muted);padding:4px 0;font-size:.8rem}.cron-table{border-collapse:collapse;width:100%;font-size:.82rem}.cron-table th{text-align:left;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;border-bottom:1px solid var(--border);padding:6px 10px;font-size:.72rem;font-weight:600}.cron-table td{border-bottom:1px solid var(--border);color:var(--text);vertical-align:middle;padding:8px 10px}.cron-table tbody tr:hover{background:var(--bg-hover)}.cron-toggle{width:34px;height:18px;display:inline-block;position:relative}.cron-toggle input{opacity:0;width:0;height:0}.cron-slider{cursor:pointer;background:var(--surface2);border:1px solid var(--border);border-radius:9999px;transition:background .2s;position:absolute;inset:0}.cron-slider:before{content:"";background:var(--muted);border-radius:50%;width:12px;height:12px;transition:transform .2s,background .2s;position:absolute;bottom:2px;left:2px}.cron-toggle input:checked+.cron-slider{background:var(--accent-subtle);border-color:var(--accent-dim)}.cron-toggle input:checked+.cron-slider:before{background:var(--accent);transform:translate(16px)}.cron-badge{border-radius:9999px;padding:2px 6px;font-size:.7rem;font-weight:500}.cron-badge.ok,.cron-badge.success{background:var(--accent-subtle);color:var(--ok)}.cron-badge.error,.cron-badge.failed{color:var(--error);background:#ef44441f}.cron-badge.running{color:var(--warn);background:#f59e0b1f}.cron-actions{gap:4px;display:flex}.cron-action-btn{border:1px solid var(--border);color:var(--muted);cursor:pointer;border-radius:var(--radius-sm);background:0 0;padding:2px 8px;font-size:.72rem;transition:color .15s,border-color .15s,background .15s}.cron-action-btn:hover{color:var(--text);border-color:var(--border-strong);background:var(--bg-hover)}.cron-action-danger:hover{color:var(--error);border-color:var(--error)}.methods-result{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);font-family:var(--font-mono);white-space:pre-wrap;word-break:break-all;max-height:300px;color:var(--text);padding:10px;font-size:.78rem;overflow-y:auto}.model-card{border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;flex-direction:column;padding:10px 12px;transition:border-color .15s,background .15s;display:flex}.model-card:hover{border-color:var(--border-strong);background:var(--bg-hover)}.tier-badge{background:var(--surface2);color:var(--muted);border:1px solid var(--border);border-radius:9999px;padding:2px 6px;font-size:.68rem;font-weight:500}.recommended-badge{background:var(--accent-subtle);color:var(--accent);border-radius:9999px;padding:2px 6px;font-size:.68rem;font-weight:500}.backend-card{border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;flex-direction:column;padding:10px 12px;transition:border-color .15s,background .15s;display:flex}.backend-card:hover:not(.disabled){border-color:var(--border-strong);background:var(--bg-hover)}.backend-card.selected{border-color:var(--accent);background:var(--accent-subtle)}.backend-card.disabled{opacity:.6;cursor:not-allowed}.backend-card.disabled .backend-name{color:var(--muted)}.install-hint{background:var(--surface2);border-radius:var(--radius-sm);color:var(--muted);font-size:.7rem;font-family:var(--font-mono);margin-top:4px;padding:6px 8px}.install-hint code{background:var(--surface);color:var(--text);border-radius:3px;padding:1px 4px}.download-progress{background:var(--surface2);border-radius:2px;height:4px;overflow:hidden}.download-progress-bar{background:var(--accent);width:0;height:100%;transition:width .3s}.download-progress.indeterminate .download-progress-bar{width:40%;animation:1.2s ease-in-out infinite indeterminate-slide}@keyframes indeterminate-slide{0%{margin-left:0}50%{margin-left:60%}to{margin-left:0}}.msg.system.download-indicator{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);max-width:400px;color:var(--text);flex-direction:column;align-self:flex-start;gap:8px;margin:12px 0;padding:16px 20px;font-size:.9rem;display:flex}.msg.system.download-indicator .download-status{color:var(--text);font-size:.85rem}.msg.system.download-indicator .download-progress{width:100%}.msg.system.download-indicator .download-progress-text{color:var(--muted);font-size:.75rem;font-family:var(--font-mono)}.provider-item-badge.local{color:#a855f7;background:#a855f71f}.onboarding-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);width:100%;max-width:520px;box-shadow:var(--shadow-lg);padding:32px;animation:.2s ease-out msg-in}.onboarding-steps{justify-content:center;align-items:center;gap:0;display:flex}.onboarding-step{flex-direction:column;flex-shrink:0;align-items:center;gap:6px;display:flex}.onboarding-step-dot{border:2px solid var(--border);width:28px;height:28px;color:var(--muted);background:var(--surface);border-radius:50%;justify-content:center;align-items:center;font-size:.75rem;font-weight:600;transition:all .2s;display:flex}.onboarding-step-dot.active{border-color:var(--accent);color:var(--accent);background:var(--accent-subtle)}.onboarding-step-dot.completed{border-color:var(--accent);background:var(--accent-dim);color:#fff}.onboarding-step-line{background:var(--border);flex-shrink:0;width:32px;height:2px;margin:0 4px 20px;transition:background .2s}.onboarding-step-line.completed{background:var(--accent-dim)}.onboarding-step-label{color:var(--muted);white-space:nowrap;font-size:.68rem}.onboarding-step.active .onboarding-step-label{color:var(--text-strong);font-weight:500}.onboarding-step.completed .onboarding-step-label{color:var(--accent)}}@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.top-1\/2{top:50%}.right-0{right:calc(var(--spacing)*0)}.right-2{right:calc(var(--spacing)*2)}.right-4{right:calc(var(--spacing)*4)}.bottom-0{bottom:calc(var(--spacing)*0)}.bottom-4{bottom:calc(var(--spacing)*4)}.left-0{left:calc(var(--spacing)*0)}.z-50{z-index:50}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.m-0{margin:calc(var(--spacing)*0)}.mx-auto{margin-inline:auto}.my-1{margin-block:calc(var(--spacing)*1)}.my-3{margin-block:calc(var(--spacing)*3)}.my-auto{margin-block:auto}.mt-0\.5{margin-top:calc(var(--spacing)*.5)}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-1\.5{margin-top:calc(var(--spacing)*1.5)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-6{margin-top:calc(var(--spacing)*6)}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-2\.5{margin-bottom:calc(var(--spacing)*2.5)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-5{margin-bottom:calc(var(--spacing)*5)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.mb-10{margin-bottom:calc(var(--spacing)*10)}.ml-1{margin-left:calc(var(--spacing)*1)}.ml-2{margin-left:calc(var(--spacing)*2)}.ml-3{margin-left:calc(var(--spacing)*3)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.h-2{height:calc(var(--spacing)*2)}.h-2\.5{height:calc(var(--spacing)*2.5)}.h-8{height:calc(var(--spacing)*8)}.h-12{height:calc(var(--spacing)*12)}.h-20{height:calc(var(--spacing)*20)}.h-64{height:calc(var(--spacing)*64)}.h-\[7px\]{height:7px}.h-full{height:100%}.max-h-48{max-height:calc(var(--spacing)*48)}.max-h-64{max-height:calc(var(--spacing)*64)}.max-h-\[120px\]{max-height:120px}.max-h-\[360px\]{max-height:360px}.min-h-0{min-height:calc(var(--spacing)*0)}.min-h-\[40px\]{min-height:40px}.min-h-\[60px\]{min-height:60px}.w-2{width:calc(var(--spacing)*2)}.w-2\.5{width:calc(var(--spacing)*2.5)}.w-8{width:calc(var(--spacing)*8)}.w-12{width:calc(var(--spacing)*12)}.w-20{width:calc(var(--spacing)*20)}.w-\[7px\]{width:7px}.w-\[240px\]{width:240px}.w-full{width:100%}.max-w-3xl{max-width:var(--container-3xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-\[200px\]{max-width:200px}.max-w-\[420px\]{max-width:420px}.max-w-\[600px\]{max-width:600px}.max-w-\[900px\]{max-width:900px}.max-w-md{max-width:var(--container-md)}.min-w-0{min-width:calc(var(--spacing)*0)}.flex-1{flex:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.-translate-y-1\/2{--tw-translate-y:calc(calc(1/2*100%)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.rotate-90{rotate:90deg}.rotate-180{rotate:180deg}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.animate-ping{animation:var(--animate-ping)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize{resize:both}.resize-none{resize:none}.resize-y{resize:vertical}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing)*.5)}.gap-1{gap:calc(var(--spacing)*1)}.gap-1\.5{gap:calc(var(--spacing)*1.5)}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}.gap-8{gap:calc(var(--spacing)*8)}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1.5)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1.5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*8)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*8)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-10>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*10)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*10)*calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing)*4)}.gap-y-3{row-gap:calc(var(--spacing)*3)}.self-center{align-self:center}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-\[var\(--radius\)\]{border-radius:var(--radius)}.rounded-\[var\(--radius-sm\)\]{border-radius:var(--radius-sm)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-t-\[var\(--radius-sm\)\]{border-top-left-radius:var(--radius-sm);border-top-right-radius:var(--radius-sm)}.rounded-b-\[var\(--radius-sm\)\]{border-bottom-right-radius:var(--radius-sm);border-bottom-left-radius:var(--radius-sm)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-t-0{border-top-style:var(--tw-border-style);border-top-width:0}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-none{--tw-border-style:none;border-style:none}.border-\[var\(--accent\)\]{border-color:var(--accent)}.border-\[var\(--border\)\]{border-color:var(--border)}.border-\[var\(--danger\)\]{border-color:var(--danger)}.border-\[var\(--error\)\]{border-color:var(--error)}.border-\[var\(--warn\)\]{border-color:var(--warn)}.border-\[var\(--warning\,\#f59e0b\)\]{border-color:var(--warning,#f59e0b)}.border-transparent{border-color:#0000}.border-t-\[var\(--accent\)\]{border-top-color:var(--accent)}.border-b-\[var\(--surface2\)\]{border-bottom-color:var(--surface2)}.bg-\[rgba\(234\,179\,8\,0\.08\)\]{background-color:#eab30814}.bg-\[var\(--accent\)\],.bg-\[var\(--accent\)\]\/10{background-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){.bg-\[var\(--accent\)\]\/10{background-color:color-mix(in oklab,var(--accent)10%,transparent)}}.bg-\[var\(--bg\)\]{background-color:var(--bg)}.bg-\[var\(--danger-bg\)\]{background-color:var(--danger-bg)}.bg-\[var\(--error\)\]{background-color:var(--error)}.bg-\[var\(--error-bg\)\]{background-color:var(--error-bg)}.bg-\[var\(--muted\)\]{background-color:var(--muted)}.bg-\[var\(--ok\)\]{background-color:var(--ok)}.bg-\[var\(--surface\)\]{background-color:var(--surface)}.bg-\[var\(--surface2\)\]{background-color:var(--surface2)}.bg-\[var\(--warn\)\]{background-color:var(--warn)}.bg-gray-500{background-color:var(--color-gray-500)}.bg-green-400{background-color:var(--color-green-400)}.bg-green-500{background-color:var(--color-green-500)}.bg-transparent{background-color:#0000}.p-0{padding:calc(var(--spacing)*0)}.p-1{padding:calc(var(--spacing)*1)}.p-1\.5{padding:calc(var(--spacing)*1.5)}.p-2{padding:calc(var(--spacing)*2)}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.p-6{padding:calc(var(--spacing)*6)}.p-10{padding:calc(var(--spacing)*10)}.px-1{padding-inline:calc(var(--spacing)*1)}.px-1\.5{padding-inline:calc(var(--spacing)*1.5)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-2\.5{padding-inline:calc(var(--spacing)*2.5)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-3\.5{padding-inline:calc(var(--spacing)*3.5)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-5{padding-inline:calc(var(--spacing)*5)}.px-6{padding-inline:calc(var(--spacing)*6)}.px-8{padding-inline:calc(var(--spacing)*8)}.py-0\.5{padding-block:calc(var(--spacing)*.5)}.py-1{padding-block:calc(var(--spacing)*1)}.py-1\.5{padding-block:calc(var(--spacing)*1.5)}.py-2{padding-block:calc(var(--spacing)*2)}.py-2\.5{padding-block:calc(var(--spacing)*2.5)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.py-6{padding-block:calc(var(--spacing)*6)}.py-10{padding-block:calc(var(--spacing)*10)}.py-20{padding-block:calc(var(--spacing)*20)}.py-px{padding-block:1px}.pt-3{padding-top:calc(var(--spacing)*3)}.pt-4{padding-top:calc(var(--spacing)*4)}.pb-3{padding-bottom:calc(var(--spacing)*3)}.pb-\[max\(1rem\,env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(1rem,env(safe-area-inset-bottom))}.pl-4{padding-left:calc(var(--spacing)*4)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.6rem\]{font-size:.6rem}.text-\[0\.7rem\]{font-size:.7rem}.text-\[0\.62rem\]{font-size:.62rem}.text-\[0\.65rem\]{font-size:.65rem}.text-\[0\.85rem\]{font-size:.85rem}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-\[var\(--font-body\)\]{--tw-font-weight:var(--font-body);font-weight:var(--font-body)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-words{overflow-wrap:break-word}.text-ellipsis{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.text-\[var\(--accent\)\]{color:var(--accent)}.text-\[var\(--danger\)\]{color:var(--danger)}.text-\[var\(--danger\,\#ef4444\)\]{color:var(--danger,#ef4444)}.text-\[var\(--error\)\]{color:var(--error)}.text-\[var\(--muted\)\]{color:var(--muted)}.text-\[var\(--ok\)\]{color:var(--ok)}.text-\[var\(--text\)\]{color:var(--text)}.text-\[var\(--text-strong\)\]{color:var(--text-strong)}.text-\[var\(--warn\)\]{color:var(--warn)}.text-green-500{color:var(--color-green-500)}.text-red-500{color:var(--color-red-500)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.no-underline{text-decoration-line:none}.underline{text-decoration-line:underline}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-75{opacity:.75}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:.15s;transition-duration:.15s}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.last\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}@media (hover:hover){.hover\:border-\[var\(--border-strong\)\]:hover{border-color:var(--border-strong)}.hover\:bg-\[var\(--bg-hover\)\]:hover{background-color:var(--bg-hover)}.hover\:bg-\[var\(--surface\)\]:hover{background-color:var(--surface)}.hover\:bg-\[var\(--surface2\)\]:hover{background-color:var(--surface2)}.hover\:text-\[var\(--accent\)\]:hover{color:var(--accent)}.hover\:text-\[var\(--text\)\]:hover{color:var(--text)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:border-\[var\(--border-strong\)\]:focus{border-color:var(--border-strong)}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-\[var\(--accent-subtle\)\]:focus{--tw-ring-color:var(--accent-subtle)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:cursor-default:disabled{cursor:default}.disabled\:opacity-40:disabled{opacity:.4}@media (min-width:48rem){.md\:hidden{display:none}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:80rem){.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}}:root,[data-theme=dark]{--bg:#0f1115;--bg-elevated:#181b22;--bg-hover:#1e2128;--surface:#14161d;--surface2:#1a1d25;--text:#e4e4e7;--text-strong:#fafafa;--muted:#71717a;--border:#27272a;--border-strong:#3f3f46;--accent:#4ade80;--accent-hover:#22c55e;--accent-dim:#16a34a;--accent-subtle:#4ade801f;--user-bg:#1e2028;--user-border:#2a2d36;--assistant-bg:#1a1d25;--assistant-border:#27272a;--error:#ef4444;--warn:#f59e0b;--ok:#22c55e;--shadow-sm:0 1px 2px #00000040;--shadow-md:0 4px 12px #0000004d;--shadow-lg:0 12px 28px #0006;--font-body:"Inter",system-ui,-apple-system,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--radius:8px;--radius-sm:6px;--radius-lg:12px;color-scheme:dark}[data-theme=light]{--bg:#fafafa;--bg-elevated:#fff;--bg-hover:#f0f0f0;--surface:#f5f5f5;--surface2:#ebebeb;--text:#3f3f46;--text-strong:#18181b;--muted:#71717a;--border:#e4e4e7;--border-strong:#d4d4d8;--accent:#16a34a;--accent-hover:#15803d;--accent-dim:#166534;--accent-subtle:#16a34a1a;--user-bg:#f0f0f0;--user-border:#d4d4d8;--assistant-bg:#f5f5f5;--assistant-border:#e4e4e7;--error:#dc2626;--warn:#d97706;--ok:#16a34a;--shadow-sm:0 1px 2px #0000000f;--shadow-md:0 4px 12px #00000014;--shadow-lg:0 12px 28px #0000001f;color-scheme:light}@keyframes pulse{50%{opacity:.5}}@keyframes msg-in{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}@keyframes dot-bounce{0%,60%,to{transform:translateY(0)}30%{transform:translateY(-4px)}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,to{opacity:0;transform:scale(2)}} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-500:oklch(63.7% .237 25.331);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-3xl:48rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height:calc(1.5/1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5/2.25);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--tracking-wide:.025em;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--shadow-sm:0 1px 3px 0 #0000001a,0 1px 2px -1px #0000001a;--shadow-md:0 4px 6px -1px #0000001a,0 2px 4px -2px #0000001a;--shadow-lg:0 10px 15px -3px #0000001a,0 4px 6px -4px #0000001a;--ease-out:cubic-bezier(0,0,.2,1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0,0,.2,1)infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*,:before,:after{box-sizing:border-box;margin:0;padding:0}html,body{height:100%;font-family:var(--font-body);background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}body{flex-direction:column;display:flex}::selection{background:var(--accent-subtle);color:var(--text-strong)}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:0 0}::-webkit-scrollbar-thumb{background:var(--border);border-radius:9999px}::-webkit-scrollbar-thumb:hover{background:var(--border-strong)}}@layer components{.status-dot{background:var(--muted);border-radius:50%;flex-shrink:0;width:8px;height:8px;transition:background .3s}.status-dot.connected{background:var(--ok)}.status-dot.connecting{background:var(--warn);animation:1s infinite pulse}.msg{border-radius:var(--radius);word-wrap:break-word;white-space:pre-wrap;max-width:80%;padding:10px 14px;font-size:.9rem;line-height:1.6;animation:.2s ease-out msg-in}.msg.user{background:var(--user-bg);border:1px solid var(--user-border);color:var(--text);align-self:flex-end}.queued-tray{border-top:1px dashed var(--border);background:var(--surface);flex-direction:column;gap:6px;padding:8px 16px;display:flex}.msg.user.queued{opacity:.6;border-style:dashed;align-self:flex-end;max-width:100%}.queued-badge{color:var(--muted);align-items:center;gap:6px;margin-top:6px;font-size:.75rem;display:flex}.queued-label{font-style:italic}.queued-cancel{border:1px solid var(--border);color:var(--muted);cursor:pointer;background:0 0;border-radius:4px;padding:1px 6px;font-size:.7rem;transition:color .15s,border-color .15s}.queued-cancel:hover{color:var(--error);border-color:var(--error)}.msg.assistant{background:var(--assistant-bg);border:1px solid var(--assistant-border);color:var(--text);align-self:flex-start}.msg.system{color:var(--muted);background:0 0;align-self:center;padding:4px 0;font-size:.8rem}.msg.error{color:var(--error);background:0 0;align-self:center;padding:4px 0;font-size:.8rem}.msg.error-card{background:var(--surface);border:1px solid var(--error);border-radius:8px;align-self:center;align-items:flex-start;gap:10px;max-width:420px;padding:12px 16px;display:flex}.error-icon{flex-shrink:0;font-size:1.4rem;line-height:1}.error-body{flex:1;min-width:0}.error-title{color:var(--error);margin-bottom:2px;font-size:.85rem;font-weight:600}.error-detail{color:var(--fg);opacity:.85;font-size:.8rem}.error-countdown{color:var(--muted);white-space:nowrap;flex-shrink:0;align-self:center;font-size:.75rem}.error-countdown.reset-ready{color:var(--success,#22c55e);font-weight:600}.msg.thinking{background:var(--assistant-bg);border:1px solid var(--assistant-border);align-items:center;padding:14px 18px;display:flex}.thinking-dots{align-items:center;gap:5px;display:flex}.thinking-dots span{background:var(--muted);border-radius:50%;width:7px;height:7px;animation:1.2s ease-in-out infinite dot-bounce}.thinking-dots span:nth-child(2){animation-delay:.15s}.thinking-dots span:nth-child(3){animation-delay:.3s}.thinking-text{color:var(--muted);white-space:pre-wrap;text-overflow:ellipsis;-webkit-line-clamp:2;-webkit-box-orient:vertical;font-size:.82rem;font-style:italic;line-height:1.4;animation:2s ease-in-out infinite thinking-pulse;display:-webkit-box;overflow:hidden}@keyframes thinking-pulse{0%,to{opacity:.6}50%{opacity:1}}.msg.tool-event{font-family:var(--font-mono);font-size:.78rem}.msg.tool-ok{color:var(--ok)}.msg.tool-err{color:var(--error)}.msg.exec-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);white-space:normal;flex-direction:column;align-self:flex-start;gap:4px;max-width:70%;padding:8px 12px;display:flex}.msg.exec-card.exec-ok{border-color:var(--border)}.msg.exec-card.exec-err{border-color:var(--error)}.exec-prompt{font-family:var(--font-mono);color:var(--text);font-size:.8rem;line-height:1.4}.exec-prompt-char{color:var(--ok);-webkit-user-select:none;user-select:none;font-weight:600}.exec-card.exec-err .exec-prompt-char{color:var(--error)}.exec-status{color:var(--muted);font-size:.72rem;font-style:italic}.exec-output{font-family:var(--font-mono);color:var(--text);background:var(--bg);border:1px solid var(--border);white-space:pre;border-radius:4px;max-height:200px;margin:0;padding:6px 8px;font-size:.78rem;line-height:1.45;overflow:auto}.exec-output.exec-stderr{color:var(--warn)}.exec-exit{font-family:var(--font-mono);color:var(--error);font-size:.72rem}.exec-error-detail{color:var(--error);font-size:.78rem}.exec-card.exec-retry{opacity:.6;border-left-color:var(--muted)}.exec-retry-detail{color:var(--muted);font-size:.72rem;font-style:italic}.screenshot-container{margin-top:8px;position:relative}.screenshot-thumbnail{border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;max-width:200px;max-height:150px;transition:border-color .15s,transform .15s}.screenshot-thumbnail:hover{border-color:var(--accent);transform:scale(1.02)}.screenshot-lightbox{z-index:9999;cursor:zoom-out;background:#000000e6;justify-content:center;align-items:stretch;padding:20px;display:flex;position:fixed;inset:0}.screenshot-lightbox-content{cursor:default;flex-direction:column;align-items:center;gap:12px;width:100%;max-width:100%;max-height:100%;display:flex}.screenshot-lightbox-header{flex-shrink:0;align-items:center;gap:12px;padding:8px 0;display:flex}.screenshot-lightbox-close{color:#fff;cursor:pointer;background:#ffffff26;border:none;border-radius:50%;justify-content:center;align-items:center;width:40px;height:40px;font-size:1.25rem;font-weight:300;transition:background .15s;display:flex}.screenshot-lightbox-close:hover{background:#ffffff40}.screenshot-lightbox-scroll{border-radius:var(--radius-md);scrollbar-width:thin;scrollbar-color:var(--border-strong)transparent;flex:1;justify-content:center;align-items:flex-start;width:100%;min-height:0;display:flex;overflow:auto}.screenshot-lightbox-scroll::-webkit-scrollbar{width:8px}.screenshot-lightbox-scroll::-webkit-scrollbar-track{background:0 0}.screenshot-lightbox-scroll::-webkit-scrollbar-thumb{background:var(--border-strong);border-radius:4px}.screenshot-lightbox-img{border-radius:var(--radius-md);flex-shrink:0;display:block;box-shadow:0 4px 20px #00000080}.screenshot-download-btn{background:var(--accent-dim);color:#fff;border-radius:var(--radius-sm);cursor:pointer;border:none;padding:8px 16px;font-size:.85rem;font-weight:500;transition:background .15s}.screenshot-download-btn:hover{background:var(--accent)}.screenshot-download-btn-small{background:var(--surface);color:var(--text);border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;opacity:0;justify-content:center;align-items:center;width:24px;height:24px;font-size:.7rem;transition:opacity .15s,background .15s;display:flex;position:absolute;top:4px;right:4px}.screenshot-container:hover .screenshot-download-btn-small{opacity:1}.screenshot-download-btn-small:hover{background:var(--accent-dim);color:#fff;border-color:var(--accent-dim)}.msg code{background:var(--surface2);font-family:var(--font-mono);border-radius:4px;padding:1px 6px;font-size:.85em}.msg pre{background:var(--surface);border-radius:var(--radius-sm);border:1px solid var(--border);margin:6px 0;padding:10px 12px;overflow-x:auto}.msg pre code{background:0 0;padding:0}.msg strong{color:var(--text-strong);font-weight:600}.msg-channel-footer{color:var(--muted);margin-top:4px;font-size:.7rem}.theme-toggle{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);align-items:center;gap:2px;padding:2px;display:flex}.theme-btn{width:28px;height:28px;color:var(--muted);cursor:pointer;background:0 0;border:none;border-radius:4px;justify-content:center;align-items:center;transition:all .15s;display:flex}.theme-btn:hover{color:var(--text);background:var(--bg-hover)}.theme-btn.active{color:var(--text-strong);background:var(--bg-elevated);box-shadow:var(--shadow-sm)}.theme-btn svg{width:16px;height:16px}.approval-card{background:var(--surface2);border:1px solid var(--warn);border-radius:var(--radius);flex-direction:column;align-self:center;gap:8px;max-width:90%;padding:12px 16px;display:flex}.approval-label{color:var(--warn);font-size:.8rem;font-weight:600}.approval-cmd{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);font-family:var(--font-mono);color:var(--text);white-space:pre-wrap;word-break:break-all;padding:6px 10px;font-size:.82rem;display:block}.approval-btns{gap:8px;display:flex}.approval-btn{border-radius:var(--radius-sm);cursor:pointer;border:none;padding:5px 14px;font-size:.8rem;font-weight:500;transition:opacity .15s}.approval-btn:disabled{opacity:.4;cursor:default}.approval-allow{background:var(--ok);color:#fff}.approval-deny{background:var(--error);color:#fff}.approval-countdown{color:var(--muted);text-align:right;font-size:.72rem}.approval-status{color:var(--muted);font-size:.78rem;font-style:italic}.approval-resolved{opacity:.6}.approval-expired{opacity:.4}.provider-modal-backdrop{z-index:50;background:#00000080;justify-content:center;align-items:center;display:flex;position:fixed;inset:0}.provider-modal-backdrop.hidden{display:none}.provider-modal{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-lg);flex-direction:column;width:420px;max-width:90vw;max-height:80vh;animation:.15s ease-out msg-in;display:flex}.provider-modal-header{border-bottom:1px solid var(--border);justify-content:space-between;align-items:center;padding:14px 18px;display:flex}.provider-modal-body{flex-direction:column;gap:8px;padding:16px 18px;display:flex;overflow-y:auto}.provider-item{border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;justify-content:space-between;align-items:center;padding:10px 12px;transition:border-color .15s,background .15s;display:flex}.provider-item:hover{border-color:var(--border-strong);background:var(--bg-hover)}.provider-item.configured{opacity:.6}.provider-item-name{color:var(--text);font-size:.85rem;font-weight:500}.provider-item-badge{border-radius:9999px;padding:2px 6px;font-size:.7rem;font-weight:500}.provider-item-badge.api-key{background:var(--accent-subtle);color:var(--accent)}.provider-item-badge.oauth{color:#818cf8;background:#6366f11f}.provider-item-badge.configured{background:var(--accent-subtle);color:var(--ok)}.provider-key-form{flex-direction:column;gap:10px;display:flex}.provider-key-input{background:var(--surface2);border:1px solid var(--border);width:100%;color:var(--text);border-radius:var(--radius-sm);font-size:.8rem;font-family:var(--font-mono);padding:8px 10px}.provider-key-input:focus{border-color:var(--border-strong);outline:none}.provider-btn{border-radius:var(--radius-sm);cursor:pointer;color:#fff;background:var(--accent-dim);border:none;padding:8px 16px;font-size:.8rem;font-weight:500;transition:opacity .15s}.provider-btn:hover{background:var(--accent)}.provider-btn:disabled{opacity:.4;cursor:default}.provider-btn-secondary{background:var(--surface2);color:var(--text);border:1px solid var(--border)}.provider-btn-secondary:hover{background:var(--bg-hover)}.provider-status{color:var(--ok);text-align:center;padding:8px 0;font-size:.8rem}.voice-recording-hint{border-radius:var(--radius-sm);border-left:3px solid var(--error);color:var(--text);background:#ef44441a;align-items:center;gap:8px;margin-top:6px;padding:6px 10px;font-size:.75rem;display:flex}.voice-recording-dot{background:var(--error);border-radius:50%;width:8px;height:8px;animation:1s ease-in-out infinite pulse}@keyframes pulse{0%,to{opacity:1}50%{opacity:.4}}.voice-transcription-result{background:var(--accent-subtle);border-radius:var(--radius-sm);border-left:3px solid var(--accent);align-items:baseline;gap:6px;margin-top:6px;padding:8px 10px;display:flex}.voice-success-result{background:var(--accent-subtle);border-radius:var(--radius-sm);border-left:3px solid var(--accent);color:var(--accent);align-items:center;gap:6px;margin-top:6px;padding:6px 10px;font-size:.75rem;display:flex}.voice-transcription-label{color:var(--accent);text-transform:uppercase;letter-spacing:.02em;flex-shrink:0;font-size:.7rem;font-weight:500}.voice-transcription-text{color:var(--text);font-size:.8rem;font-style:italic}.msg.voice-transcribing{background:var(--user-bg);border:1px solid var(--user-border);border-radius:var(--radius);align-self:flex-end;align-items:center;gap:10px;padding:10px 14px;animation:.15s ease-out msg-in;display:flex}.voice-transcribing-spinner{border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;width:16px;height:16px;animation:.8s linear infinite spin}@keyframes spin{to{transform:rotate(360deg)}}.voice-transcribing-text{color:var(--muted);font-size:.85rem}.nav-panel{border-right:1px solid var(--border);background:var(--surface);flex-direction:column;flex-shrink:0;width:180px;display:flex}.nav-panel.hidden{display:none}.nav-link{color:var(--muted);padding:8px 16px;font-size:.85rem;text-decoration:none;transition:background .15s,color .15s;display:block}.nav-link:hover{background:var(--bg-hover);color:var(--text)}.nav-link.active{color:var(--accent);background:var(--accent-subtle)}.model-combo{position:relative}.model-combo.hidden{display:none}.model-combo-btn{background:var(--surface2);border:1px solid var(--border);color:var(--muted);border-radius:var(--radius-sm);font-size:.75rem;font-family:var(--font-body);cursor:pointer;white-space:nowrap;text-overflow:ellipsis;align-items:center;gap:4px;max-width:220px;padding:4px 8px;transition:border-color .15s,color .15s;display:flex;overflow:hidden}.model-combo-btn:hover,.model-combo-btn:focus{border-color:var(--border-strong);color:var(--text);outline:none}.model-combo-chevron{opacity:.6;flex-shrink:0}.model-dropdown{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);width:280px;box-shadow:var(--shadow-md);z-index:40;flex-direction:column;animation:.1s ease-out msg-in;display:flex;position:absolute;top:calc(100% + 4px);left:0}.model-dropdown.hidden{display:none}.model-search-input{background:var(--surface2);border:none;border-bottom:1px solid var(--border);width:100%;color:var(--text);font-size:.78rem;font-family:var(--font-body);outline:none;padding:8px 10px}.model-search-input::placeholder{color:var(--muted)}.model-dropdown-list{max-height:240px;padding:4px 0;overflow-y:auto}.model-dropdown-item{cursor:pointer;justify-content:space-between;align-items:center;padding:6px 10px;transition:background .1s;display:flex}.model-dropdown-item:hover,.model-dropdown-item.kb-active{background:var(--bg-hover)}.model-dropdown-item.selected{color:var(--accent)}.model-item-label{white-space:nowrap;text-overflow:ellipsis;min-width:0;font-size:.78rem;overflow:hidden}.model-item-provider{color:var(--muted);flex-shrink:0;margin-left:8px;font-size:.68rem}.model-dropdown-empty{color:var(--muted);padding:8px 10px;font-size:.78rem}.session-item{cursor:pointer;border-bottom:1px solid var(--border);justify-content:space-between;align-items:center;padding:8px 12px;transition:background .15s;display:flex}.session-item:hover{background:var(--bg-hover)}.session-item.active{background:var(--accent-subtle);border-left:3px solid var(--accent)}.session-info{flex:1;min-width:0}.session-label{color:var(--text);white-space:nowrap;align-items:center;gap:4px;min-width:0;font-size:.82rem;display:flex}.session-label [data-label-text]{text-overflow:ellipsis;overflow:hidden}.session-meta{color:var(--muted);margin-top:2px;font-size:.7rem}.session-actions{opacity:0;flex-shrink:0;gap:4px;transition:opacity .15s;display:flex}.session-item:hover .session-actions{opacity:1}.session-action-btn{color:var(--muted);cursor:pointer;background:0 0;border:none;border-radius:3px;padding:2px 4px;font-size:.75rem;transition:color .15s,background .15s}.session-action-btn:hover{color:var(--text);background:var(--surface2)}.session-fork-btn{color:var(--muted);cursor:pointer;background:0 0;border:none;border-radius:3px;flex-shrink:0;align-items:center;padding:2px 4px;transition:color .15s,background .15s;display:inline-flex}.session-fork-btn svg{flex-shrink:0;min-width:14px;min-height:14px}.session-fork-btn:hover{color:var(--text);background:var(--surface2)}.session-delete:hover{color:var(--error)}.session-icon{flex-shrink:0;justify-content:center;align-items:center;width:16px;height:16px;display:inline-flex}.session-spinner{color:var(--warn);-webkit-user-select:none;user-select:none;font-family:monospace;font-size:.85rem;line-height:16px;display:none}.session-item.replying .session-icon>svg{display:none}.session-item.replying .session-spinner{display:inline}.session-item.unread .session-label:before{content:"";background:var(--accent);vertical-align:middle;border-radius:50%;width:7px;height:7px;margin-right:6px;display:inline-block}.token-bar{color:var(--muted);text-align:center;border-top:1px solid var(--border);background:var(--surface);padding:2px 12px;font-size:.7rem}.token-bar:empty{display:none}.msg-model-footer{color:var(--muted);text-align:right;margin-top:4px;font-size:.68rem}.cron-status-bar{color:var(--muted);padding:4px 0;font-size:.8rem}.cron-table{border-collapse:collapse;width:100%;font-size:.82rem}.cron-table th{text-align:left;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;border-bottom:1px solid var(--border);padding:6px 10px;font-size:.72rem;font-weight:600}.cron-table td{border-bottom:1px solid var(--border);color:var(--text);vertical-align:middle;padding:8px 10px}.cron-table tbody tr:hover{background:var(--bg-hover)}.cron-toggle{width:34px;height:18px;display:inline-block;position:relative}.cron-toggle input{opacity:0;width:0;height:0}.cron-slider{cursor:pointer;background:var(--surface2);border:1px solid var(--border);border-radius:9999px;transition:background .2s;position:absolute;inset:0}.cron-slider:before{content:"";background:var(--muted);border-radius:50%;width:12px;height:12px;transition:transform .2s,background .2s;position:absolute;bottom:2px;left:2px}.cron-toggle input:checked+.cron-slider{background:var(--accent-subtle);border-color:var(--accent-dim)}.cron-toggle input:checked+.cron-slider:before{background:var(--accent);transform:translate(16px)}.cron-badge{border-radius:9999px;padding:2px 6px;font-size:.7rem;font-weight:500}.cron-badge.ok,.cron-badge.success{background:var(--accent-subtle);color:var(--ok)}.cron-badge.error,.cron-badge.failed{color:var(--error);background:#ef44441f}.cron-badge.running{color:var(--warn);background:#f59e0b1f}.cron-actions{gap:4px;display:flex}.cron-action-btn{border:1px solid var(--border);color:var(--muted);cursor:pointer;border-radius:var(--radius-sm);background:0 0;padding:2px 8px;font-size:.72rem;transition:color .15s,border-color .15s,background .15s}.cron-action-btn:hover{color:var(--text);border-color:var(--border-strong);background:var(--bg-hover)}.cron-action-danger:hover{color:var(--error);border-color:var(--error)}.methods-result{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);font-family:var(--font-mono);white-space:pre-wrap;word-break:break-all;max-height:300px;color:var(--text);padding:10px;font-size:.78rem;overflow-y:auto}.model-card{border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;flex-direction:column;padding:10px 12px;transition:border-color .15s,background .15s;display:flex}.model-card:hover{border-color:var(--border-strong);background:var(--bg-hover)}.tier-badge{background:var(--surface2);color:var(--muted);border:1px solid var(--border);border-radius:9999px;padding:2px 6px;font-size:.68rem;font-weight:500}.recommended-badge{background:var(--accent-subtle);color:var(--accent);border-radius:9999px;padding:2px 6px;font-size:.68rem;font-weight:500}.backend-card{border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;flex-direction:column;padding:10px 12px;transition:border-color .15s,background .15s;display:flex}.backend-card:hover:not(.disabled){border-color:var(--border-strong);background:var(--bg-hover)}.backend-card.selected{border-color:var(--accent);background:var(--accent-subtle)}.backend-card.disabled{opacity:.6;cursor:not-allowed}.backend-card.disabled .backend-name{color:var(--muted)}.install-hint{background:var(--surface2);border-radius:var(--radius-sm);color:var(--muted);font-size:.7rem;font-family:var(--font-mono);margin-top:4px;padding:6px 8px}.install-hint code{background:var(--surface);color:var(--text);border-radius:3px;padding:1px 4px}.download-progress{background:var(--surface2);border-radius:2px;height:4px;overflow:hidden}.download-progress-bar{background:var(--accent);width:0;height:100%;transition:width .3s}.download-progress.indeterminate .download-progress-bar{width:40%;animation:1.2s ease-in-out infinite indeterminate-slide}@keyframes indeterminate-slide{0%{margin-left:0}50%{margin-left:60%}to{margin-left:0}}.msg.system.download-indicator{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);max-width:400px;color:var(--text);flex-direction:column;align-self:flex-start;gap:8px;margin:12px 0;padding:16px 20px;font-size:.9rem;display:flex}.msg.system.download-indicator .download-status{color:var(--text);font-size:.85rem}.msg.system.download-indicator .download-progress{width:100%}.msg.system.download-indicator .download-progress-text{color:var(--muted);font-size:.75rem;font-family:var(--font-mono)}.provider-item-badge.local{color:#a855f7;background:#a855f71f}.onboarding-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);width:100%;max-width:520px;box-shadow:var(--shadow-lg);padding:32px;animation:.2s ease-out msg-in}.onboarding-steps{justify-content:center;align-items:center;gap:0;display:flex}.onboarding-step{flex-direction:column;flex-shrink:0;align-items:center;gap:6px;display:flex}.onboarding-step-dot{border:2px solid var(--border);width:28px;height:28px;color:var(--muted);background:var(--surface);border-radius:50%;justify-content:center;align-items:center;font-size:.75rem;font-weight:600;transition:all .2s;display:flex}.onboarding-step-dot.active{border-color:var(--accent);color:var(--accent);background:var(--accent-subtle)}.onboarding-step-dot.completed{border-color:var(--accent);background:var(--accent-dim);color:#fff}.onboarding-step-line{background:var(--border);flex-shrink:0;width:32px;height:2px;margin:0 4px 20px;transition:background .2s}.onboarding-step-line.completed{background:var(--accent-dim)}.onboarding-step-label{color:var(--muted);white-space:nowrap;font-size:.68rem}.onboarding-step.active .onboarding-step-label{color:var(--text-strong);font-weight:500}.onboarding-step.completed .onboarding-step-label{color:var(--accent)}}@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.top-1\/2{top:50%}.right-0{right:calc(var(--spacing)*0)}.right-2{right:calc(var(--spacing)*2)}.right-4{right:calc(var(--spacing)*4)}.bottom-0{bottom:calc(var(--spacing)*0)}.bottom-4{bottom:calc(var(--spacing)*4)}.left-0{left:calc(var(--spacing)*0)}.z-50{z-index:50}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.m-0{margin:calc(var(--spacing)*0)}.mx-auto{margin-inline:auto}.my-1{margin-block:calc(var(--spacing)*1)}.my-3{margin-block:calc(var(--spacing)*3)}.my-auto{margin-block:auto}.mt-0\.5{margin-top:calc(var(--spacing)*.5)}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-1\.5{margin-top:calc(var(--spacing)*1.5)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-6{margin-top:calc(var(--spacing)*6)}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-2\.5{margin-bottom:calc(var(--spacing)*2.5)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-5{margin-bottom:calc(var(--spacing)*5)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.mb-10{margin-bottom:calc(var(--spacing)*10)}.ml-1{margin-left:calc(var(--spacing)*1)}.ml-2{margin-left:calc(var(--spacing)*2)}.ml-3{margin-left:calc(var(--spacing)*3)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.h-2{height:calc(var(--spacing)*2)}.h-2\.5{height:calc(var(--spacing)*2.5)}.h-8{height:calc(var(--spacing)*8)}.h-12{height:calc(var(--spacing)*12)}.h-20{height:calc(var(--spacing)*20)}.h-64{height:calc(var(--spacing)*64)}.h-\[7px\]{height:7px}.h-full{height:100%}.max-h-48{max-height:calc(var(--spacing)*48)}.max-h-64{max-height:calc(var(--spacing)*64)}.max-h-\[120px\]{max-height:120px}.max-h-\[360px\]{max-height:360px}.min-h-0{min-height:calc(var(--spacing)*0)}.min-h-\[40px\]{min-height:40px}.min-h-\[60px\]{min-height:60px}.w-2{width:calc(var(--spacing)*2)}.w-2\.5{width:calc(var(--spacing)*2.5)}.w-8{width:calc(var(--spacing)*8)}.w-12{width:calc(var(--spacing)*12)}.w-20{width:calc(var(--spacing)*20)}.w-\[7px\]{width:7px}.w-\[240px\]{width:240px}.w-full{width:100%}.max-w-3xl{max-width:var(--container-3xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-\[200px\]{max-width:200px}.max-w-\[420px\]{max-width:420px}.max-w-\[600px\]{max-width:600px}.max-w-\[900px\]{max-width:900px}.max-w-md{max-width:var(--container-md)}.min-w-0{min-width:calc(var(--spacing)*0)}.flex-1{flex:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.-translate-y-1\/2{--tw-translate-y:calc(calc(1/2*100%)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.rotate-90{rotate:90deg}.rotate-180{rotate:180deg}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.animate-ping{animation:var(--animate-ping)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.cursor-wait{cursor:wait}.resize{resize:both}.resize-none{resize:none}.resize-y{resize:vertical}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing)*.5)}.gap-1{gap:calc(var(--spacing)*1)}.gap-1\.5{gap:calc(var(--spacing)*1.5)}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}.gap-8{gap:calc(var(--spacing)*8)}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1.5)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1.5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*8)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*8)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-10>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*10)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*10)*calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing)*4)}.gap-y-3{row-gap:calc(var(--spacing)*3)}.self-center{align-self:center}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-\[var\(--radius\)\]{border-radius:var(--radius)}.rounded-\[var\(--radius-sm\)\]{border-radius:var(--radius-sm)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-t-\[var\(--radius-sm\)\]{border-top-left-radius:var(--radius-sm);border-top-right-radius:var(--radius-sm)}.rounded-t-md{border-top-left-radius:var(--radius-md);border-top-right-radius:var(--radius-md)}.rounded-b-\[var\(--radius-sm\)\]{border-bottom-right-radius:var(--radius-sm);border-bottom-left-radius:var(--radius-sm)}.rounded-b-md{border-bottom-right-radius:var(--radius-md);border-bottom-left-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-t-0{border-top-style:var(--tw-border-style);border-top-width:0}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-none{--tw-border-style:none;border-style:none}.border-\[var\(--accent\)\]{border-color:var(--accent)}.border-\[var\(--border\)\]{border-color:var(--border)}.border-\[var\(--danger\)\]{border-color:var(--danger)}.border-\[var\(--error\)\]{border-color:var(--error)}.border-\[var\(--warn\)\]{border-color:var(--warn)}.border-\[var\(--warning\,\#f59e0b\)\]{border-color:var(--warning,#f59e0b)}.border-transparent{border-color:#0000}.border-t-\[var\(--accent\)\]{border-top-color:var(--accent)}.border-b-\[var\(--surface2\)\]{border-bottom-color:var(--surface2)}.bg-\[rgba\(234\,179\,8\,0\.08\)\]{background-color:#eab30814}.bg-\[var\(--accent\)\],.bg-\[var\(--accent\)\]\/10{background-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){.bg-\[var\(--accent\)\]\/10{background-color:color-mix(in oklab,var(--accent)10%,transparent)}}.bg-\[var\(--bg\)\]{background-color:var(--bg)}.bg-\[var\(--danger-bg\)\]{background-color:var(--danger-bg)}.bg-\[var\(--error\)\]{background-color:var(--error)}.bg-\[var\(--error-bg\)\]{background-color:var(--error-bg)}.bg-\[var\(--muted\)\]{background-color:var(--muted)}.bg-\[var\(--ok\)\]{background-color:var(--ok)}.bg-\[var\(--surface\)\]{background-color:var(--surface)}.bg-\[var\(--surface2\)\]{background-color:var(--surface2)}.bg-\[var\(--warn\)\]{background-color:var(--warn)}.bg-gray-500{background-color:var(--color-gray-500)}.bg-green-400{background-color:var(--color-green-400)}.bg-green-500{background-color:var(--color-green-500)}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.p-0{padding:calc(var(--spacing)*0)}.p-1{padding:calc(var(--spacing)*1)}.p-1\.5{padding:calc(var(--spacing)*1.5)}.p-2{padding:calc(var(--spacing)*2)}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.p-6{padding:calc(var(--spacing)*6)}.p-8{padding:calc(var(--spacing)*8)}.p-10{padding:calc(var(--spacing)*10)}.px-1{padding-inline:calc(var(--spacing)*1)}.px-1\.5{padding-inline:calc(var(--spacing)*1.5)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-2\.5{padding-inline:calc(var(--spacing)*2.5)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-3\.5{padding-inline:calc(var(--spacing)*3.5)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-5{padding-inline:calc(var(--spacing)*5)}.px-6{padding-inline:calc(var(--spacing)*6)}.px-8{padding-inline:calc(var(--spacing)*8)}.py-0\.5{padding-block:calc(var(--spacing)*.5)}.py-1{padding-block:calc(var(--spacing)*1)}.py-1\.5{padding-block:calc(var(--spacing)*1.5)}.py-2{padding-block:calc(var(--spacing)*2)}.py-2\.5{padding-block:calc(var(--spacing)*2.5)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.py-10{padding-block:calc(var(--spacing)*10)}.py-20{padding-block:calc(var(--spacing)*20)}.py-px{padding-block:1px}.pt-3{padding-top:calc(var(--spacing)*3)}.pt-4{padding-top:calc(var(--spacing)*4)}.pb-3{padding-bottom:calc(var(--spacing)*3)}.pb-\[max\(1rem\,env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(1rem,env(safe-area-inset-bottom))}.pl-4{padding-left:calc(var(--spacing)*4)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.6rem\]{font-size:.6rem}.text-\[0\.7rem\]{font-size:.7rem}.text-\[0\.62rem\]{font-size:.62rem}.text-\[0\.65rem\]{font-size:.65rem}.text-\[0\.85rem\]{font-size:.85rem}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-\[var\(--font-body\)\]{--tw-font-weight:var(--font-body);font-weight:var(--font-body)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-words{overflow-wrap:break-word}.text-ellipsis{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.text-\[var\(--accent\)\]{color:var(--accent)}.text-\[var\(--danger\)\]{color:var(--danger)}.text-\[var\(--danger\,\#ef4444\)\]{color:var(--danger,#ef4444)}.text-\[var\(--error\)\]{color:var(--error)}.text-\[var\(--muted\)\]{color:var(--muted)}.text-\[var\(--ok\)\]{color:var(--ok)}.text-\[var\(--text\)\]{color:var(--text)}.text-\[var\(--text-strong\)\]{color:var(--text-strong)}.text-\[var\(--warn\)\]{color:var(--warn)}.text-gray-600{color:var(--color-gray-600)}.text-green-500{color:var(--color-green-500)}.text-red-500{color:var(--color-red-500)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,)var(--tw-slashed-zero,)var(--tw-numeric-figure,)var(--tw-numeric-spacing,)var(--tw-numeric-fraction,)}.no-underline{text-decoration-line:none}.underline{text-decoration-line:underline}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-75{opacity:.75}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:.15s;transition-duration:.15s}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.last\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}@media (hover:hover){.hover\:border-\[var\(--border-strong\)\]:hover{border-color:var(--border-strong)}.hover\:bg-\[var\(--bg-hover\)\]:hover{background-color:var(--bg-hover)}.hover\:bg-\[var\(--surface\)\]:hover{background-color:var(--surface)}.hover\:bg-\[var\(--surface2\)\]:hover{background-color:var(--surface2)}.hover\:text-\[var\(--accent\)\]:hover{color:var(--accent)}.hover\:text-\[var\(--text\)\]:hover{color:var(--text)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:border-\[var\(--border-strong\)\]:focus{border-color:var(--border-strong)}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-\[var\(--accent-subtle\)\]:focus{--tw-ring-color:var(--accent-subtle)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:cursor-default:disabled{cursor:default}.disabled\:opacity-40:disabled{opacity:.4}@media (min-width:48rem){.md\:hidden{display:none}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:80rem){.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}}:root,[data-theme=dark]{--bg:#0f1115;--bg-elevated:#181b22;--bg-hover:#1e2128;--surface:#14161d;--surface2:#1a1d25;--text:#e4e4e7;--text-strong:#fafafa;--muted:#71717a;--border:#27272a;--border-strong:#3f3f46;--accent:#4ade80;--accent-hover:#22c55e;--accent-dim:#16a34a;--accent-subtle:#4ade801f;--user-bg:#1e2028;--user-border:#2a2d36;--assistant-bg:#1a1d25;--assistant-border:#27272a;--error:#ef4444;--warn:#f59e0b;--ok:#22c55e;--shadow-sm:0 1px 2px #00000040;--shadow-md:0 4px 12px #0000004d;--shadow-lg:0 12px 28px #0006;--font-body:"Inter",system-ui,-apple-system,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--radius:8px;--radius-sm:6px;--radius-lg:12px;color-scheme:dark}[data-theme=light]{--bg:#fafafa;--bg-elevated:#fff;--bg-hover:#f0f0f0;--surface:#f5f5f5;--surface2:#ebebeb;--text:#3f3f46;--text-strong:#18181b;--muted:#71717a;--border:#e4e4e7;--border-strong:#d4d4d8;--accent:#16a34a;--accent-hover:#15803d;--accent-dim:#166534;--accent-subtle:#16a34a1a;--user-bg:#f0f0f0;--user-border:#d4d4d8;--assistant-bg:#f5f5f5;--assistant-border:#e4e4e7;--error:#dc2626;--warn:#d97706;--ok:#16a34a;--shadow-sm:0 1px 2px #0000000f;--shadow-md:0 4px 12px #00000014;--shadow-lg:0 12px 28px #0000001f;color-scheme:light}@keyframes pulse{50%{opacity:.5}}@keyframes msg-in{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}@keyframes dot-bounce{0%,60%,to{transform:translateY(0)}30%{transform:translateY(-4px)}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,to{opacity:0;transform:scale(2)}} \ No newline at end of file diff --git a/crates/gateway/src/channel.rs b/crates/gateway/src/channel.rs index dfa49890..29ea4d0e 100644 --- a/crates/gateway/src/channel.rs +++ b/crates/gateway/src/channel.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::{collections::HashMap, sync::Arc}; use { async_trait::async_trait, @@ -7,14 +7,15 @@ use { tracing::{error, info, warn}, }; -use {moltis_channels::ChannelPlugin, moltis_telegram::TelegramPlugin}; - use { moltis_channels::{ + ChannelPlugin, ChannelType, message_log::MessageLog, + plugin::ChannelOutbound, store::{ChannelStore, StoredChannel}, }, moltis_sessions::metadata::SqliteSessionMetadata, + moltis_telegram::TelegramPlugin, }; use crate::services::{ChannelService, ServiceResult}; @@ -26,104 +27,237 @@ fn unix_now() -> i64 { .as_secs() as i64 } -/// Live channel service backed by `TelegramPlugin`. +/// Multi-channel service supporting Telegram and WhatsApp. +/// +/// Each plugin type is stored as a concrete field behind its feature flag. +/// Telegram-specific features (OTP, allowlist hot-update) use the direct +/// `telegram` reference. The `account_types` reverse map is shared with +/// `MultiChannelOutbound` for routing. pub struct LiveChannelService { - telegram: Arc>, store: Arc, message_log: Arc, session_metadata: Arc, + /// Reverse map: account_id → ChannelType. Shared with `MultiChannelOutbound`. + account_types: Arc>>, + /// Direct reference to the Telegram plugin. + telegram: Option>>, + /// Direct reference to the WhatsApp plugin. + #[cfg(feature = "whatsapp")] + whatsapp: Option>>, } impl LiveChannelService { pub fn new( - telegram: TelegramPlugin, store: Arc, message_log: Arc, session_metadata: Arc, ) -> Self { Self { - telegram: Arc::new(RwLock::new(telegram)), store, message_log, session_metadata, + account_types: Arc::new(RwLock::new(HashMap::new())), + telegram: None, + #[cfg(feature = "whatsapp")] + whatsapp: None, } } -} -#[async_trait] -impl ChannelService for LiveChannelService { - async fn status(&self) -> ServiceResult { - let tg = self.telegram.read().await; + /// Register a Telegram plugin. + pub fn register_telegram(&mut self, plugin: TelegramPlugin) { + self.telegram = Some(Arc::new(RwLock::new(plugin))); + } + + /// Register a WhatsApp plugin. + #[cfg(feature = "whatsapp")] + pub fn register_whatsapp(&mut self, plugin: moltis_whatsapp::WhatsAppPlugin) { + self.whatsapp = Some(Arc::new(RwLock::new(plugin))); + } + + /// Get a shared reference to the account_types map (for `MultiChannelOutbound`). + pub fn account_types(&self) -> Arc>> { + Arc::clone(&self.account_types) + } + + /// Resolve account_id → ChannelType from the in-memory reverse map, + /// falling back to the persistent store. + async fn resolve_type(&self, account_id: &str) -> Option { + { + let map = self.account_types.read().await; + if let Some(ct) = map.get(account_id) { + return Some(*ct); + } + } + // Fall back to store. + if let Ok(Some(stored)) = self.store.get(account_id).await + && let Ok(ct) = stored.channel_type.parse::() + { + let mut map = self.account_types.write().await; + map.insert(account_id.to_string(), ct); + return Some(ct); + } + None + } + + /// Record an account → type mapping. + async fn track_account(&self, account_id: &str, ct: ChannelType) { + let mut map = self.account_types.write().await; + map.insert(account_id.to_string(), ct); + } + + /// Remove an account → type mapping. + async fn untrack_account(&self, account_id: &str) { + let mut map = self.account_types.write().await; + map.remove(account_id); + } + + /// Helper: build session info for an account. + async fn session_info(&self, ct_str: &str, account_id: &str) -> Vec { + let bound = self + .session_metadata + .list_account_sessions(ct_str, account_id) + .await; + let active_map = self + .session_metadata + .list_active_sessions(ct_str, account_id) + .await; + bound + .iter() + .map(|s| { + let is_active = active_map.iter().any(|(_, sk)| sk == &s.key); + serde_json::json!({ + "key": s.key, + "label": s.label, + "messageCount": s.message_count, + "active": is_active, + }) + }) + .collect() + } + + /// Collect Telegram channel status entries. + async fn telegram_status(&self) -> Vec { + let mut channels = Vec::new(); + let Some(ref tg_arc) = self.telegram else { + return channels; + }; + let tg = tg_arc.read().await; let account_ids = tg.account_ids(); + let Some(status) = tg.status() else { + return channels; + }; + + let ct_str = ChannelType::Telegram.as_str(); + for aid in &account_ids { + match status.probe(aid).await { + Ok(snap) => { + let mut entry = serde_json::json!({ + "type": ct_str, + "name": format!("Telegram ({aid})"), + "account_id": aid, + "status": if snap.connected { "connected" } else { "disconnected" }, + "details": snap.details, + }); + if let Some(cfg) = tg.account_config(aid) { + entry["config"] = cfg; + } + let sessions = self.session_info(ct_str, aid).await; + if !sessions.is_empty() { + entry["sessions"] = serde_json::json!(sessions); + } + channels.push(entry); + }, + Err(e) => { + channels.push(serde_json::json!({ + "type": ct_str, + "name": format!("Telegram ({aid})"), + "account_id": aid, + "status": "error", + "details": e.to_string(), + })); + }, + } + } + channels + } + + /// Collect WhatsApp channel status entries. + #[cfg(feature = "whatsapp")] + async fn whatsapp_status(&self) -> Vec { let mut channels = Vec::new(); + let Some(ref wa_arc) = self.whatsapp else { + return channels; + }; + let wa = wa_arc.read().await; + let account_ids = wa.account_ids(); + let Some(status) = wa.status() else { + return channels; + }; - if let Some(status) = tg.status() { - for aid in &account_ids { - match status.probe(aid).await { - Ok(snap) => { - let mut entry = serde_json::json!({ - "type": "telegram", - "name": format!("Telegram ({})", aid), - "account_id": aid, - "status": if snap.connected { "connected" } else { "disconnected" }, - "details": snap.details, - }); - if let Some(cfg) = tg.account_config(aid) { - entry["config"] = cfg; - } - - // Include bound sessions and active session mappings. - let bound = self - .session_metadata - .list_account_sessions("telegram", aid) - .await; - let active_map = self - .session_metadata - .list_active_sessions("telegram", aid) - .await; - let sessions: Vec<_> = bound - .iter() - .map(|s| { - let is_active = active_map.iter().any(|(_, sk)| sk == &s.key); - serde_json::json!({ - "key": s.key, - "label": s.label, - "messageCount": s.message_count, - "active": is_active, - }) - }) - .collect(); - if !sessions.is_empty() { - entry["sessions"] = serde_json::json!(sessions); - } - - channels.push(entry); - }, - Err(e) => { - channels.push(serde_json::json!({ - "type": "telegram", - "name": format!("Telegram ({})", aid), - "account_id": aid, - "status": "error", - "details": e.to_string(), - })); - }, - } + let ct_str = ChannelType::Whatsapp.as_str(); + for aid in &account_ids { + match status.probe(aid).await { + Ok(snap) => { + let qr = wa.latest_qr(aid); + let status_str = if snap.connected { + "connected" + } else if qr.is_some() { + "pairing" + } else { + "disconnected" + }; + let mut entry = serde_json::json!({ + "type": ct_str, + "name": format!("WhatsApp ({aid})"), + "account_id": aid, + "status": status_str, + "details": snap.details, + }); + if let Some(ref qr_data) = qr { + entry["qr_data"] = serde_json::json!(qr_data); + } + if let Some(cfg) = wa.account_config(aid) { + entry["config"] = cfg; + } + let sessions = self.session_info(ct_str, aid).await; + if !sessions.is_empty() { + entry["sessions"] = serde_json::json!(sessions); + } + channels.push(entry); + }, + Err(e) => { + channels.push(serde_json::json!({ + "type": ct_str, + "name": format!("WhatsApp ({aid})"), + "account_id": aid, + "status": "error", + "details": e.to_string(), + })); + }, } } + channels + } +} +#[async_trait] +impl ChannelService for LiveChannelService { + async fn status(&self) -> ServiceResult { + let mut channels = self.telegram_status().await; + #[cfg(feature = "whatsapp")] + channels.extend(self.whatsapp_status().await); Ok(serde_json::json!({ "channels": channels })) } async fn add(&self, params: Value) -> ServiceResult { - let channel_type = params + let channel_type_str = params .get("type") .and_then(|v| v.as_str()) .unwrap_or("telegram"); - if channel_type != "telegram" { - return Err(format!("unsupported channel type: {channel_type}")); - } + let ct: ChannelType = channel_type_str + .parse() + .map_err(|_| format!("unsupported channel type: {channel_type_str}"))?; let account_id = params .get("account_id") @@ -135,22 +269,46 @@ impl ChannelService for LiveChannelService { .cloned() .unwrap_or(Value::Object(Default::default())); - info!(account_id, "adding telegram channel account"); - - let mut tg = self.telegram.write().await; - tg.start_account(account_id, config.clone()) - .await - .map_err(|e| { - error!(error = %e, account_id, "failed to start telegram account"); - e.to_string() - })?; + info!(account_id, channel_type = %ct, "adding channel account"); + + match ct { + ChannelType::Telegram => { + if let Some(ref tg_arc) = self.telegram { + let mut tg = tg_arc.write().await; + tg.start_account(account_id, config.clone()) + .await + .map_err(|e| { + error!(error = %e, account_id, "failed to start telegram account"); + e.to_string() + })?; + } else { + return Err("telegram plugin not registered".into()); + } + }, + #[cfg(feature = "whatsapp")] + ChannelType::Whatsapp => { + if let Some(ref wa_arc) = self.whatsapp { + let mut wa = wa_arc.write().await; + wa.start_account(account_id, config.clone()) + .await + .map_err(|e| { + error!(error = %e, account_id, "failed to start whatsapp account"); + e.to_string() + })?; + } else { + return Err("whatsapp plugin not registered".into()); + } + }, + #[allow(unreachable_patterns)] + _ => return Err(format!("unsupported channel type: {ct}")), + } let now = unix_now(); if let Err(e) = self .store .upsert(StoredChannel { account_id: account_id.to_string(), - channel_type: "telegram".into(), + channel_type: ct.to_string(), config, created_at: now, updated_at: now, @@ -160,6 +318,8 @@ impl ChannelService for LiveChannelService { warn!(error = %e, account_id, "failed to persist channel"); } + self.track_account(account_id, ct).await; + Ok(serde_json::json!({ "added": account_id })) } @@ -169,18 +329,43 @@ impl ChannelService for LiveChannelService { .and_then(|v| v.as_str()) .ok_or_else(|| "missing 'account_id'".to_string())?; - info!(account_id, "removing telegram channel account"); - - let mut tg = self.telegram.write().await; - tg.stop_account(account_id).await.map_err(|e| { - error!(error = %e, account_id, "failed to stop telegram account"); - e.to_string() - })?; + let ct = self + .resolve_type(account_id) + .await + .ok_or_else(|| format!("unknown account: {account_id}"))?; + + info!(account_id, channel_type = %ct, "removing channel account"); + + match ct { + ChannelType::Telegram => { + if let Some(ref tg_arc) = self.telegram { + let mut tg = tg_arc.write().await; + tg.stop_account(account_id).await.map_err(|e| { + error!(error = %e, account_id, "failed to stop telegram account"); + e.to_string() + })?; + } + }, + #[cfg(feature = "whatsapp")] + ChannelType::Whatsapp => { + if let Some(ref wa_arc) = self.whatsapp { + let mut wa = wa_arc.write().await; + wa.stop_account(account_id).await.map_err(|e| { + error!(error = %e, account_id, "failed to stop whatsapp account"); + e.to_string() + })?; + } + }, + #[allow(unreachable_patterns)] + _ => {}, + } if let Err(e) = self.store.delete(account_id).await { warn!(error = %e, account_id, "failed to delete channel from store"); } + self.untrack_account(account_id).await; + Ok(serde_json::json!({ "removed": account_id })) } @@ -199,29 +384,55 @@ impl ChannelService for LiveChannelService { .cloned() .ok_or_else(|| "missing 'config'".to_string())?; - info!(account_id, "updating telegram channel account"); - - let mut tg = self.telegram.write().await; - - // Stop then restart with new config - tg.stop_account(account_id).await.map_err(|e| { - error!(error = %e, account_id, "failed to stop telegram account for update"); - e.to_string() - })?; - - tg.start_account(account_id, config.clone()) + let ct = self + .resolve_type(account_id) .await - .map_err(|e| { - error!(error = %e, account_id, "failed to restart telegram account after update"); - e.to_string() - })?; + .ok_or_else(|| format!("unknown account: {account_id}"))?; + + info!(account_id, channel_type = %ct, "updating channel account"); + + match ct { + ChannelType::Telegram => { + if let Some(ref tg_arc) = self.telegram { + let mut tg = tg_arc.write().await; + tg.stop_account(account_id).await.map_err(|e| { + error!(error = %e, account_id, "failed to stop telegram for update"); + e.to_string() + })?; + tg.start_account(account_id, config.clone()) + .await + .map_err(|e| { + error!(error = %e, account_id, "failed to restart telegram after update"); + e.to_string() + })?; + } + }, + #[cfg(feature = "whatsapp")] + ChannelType::Whatsapp => { + if let Some(ref wa_arc) = self.whatsapp { + let mut wa = wa_arc.write().await; + wa.stop_account(account_id).await.map_err(|e| { + error!(error = %e, account_id, "failed to stop whatsapp for update"); + e.to_string() + })?; + wa.start_account(account_id, config.clone()) + .await + .map_err(|e| { + error!(error = %e, account_id, "failed to restart whatsapp after update"); + e.to_string() + })?; + } + }, + #[allow(unreachable_patterns)] + _ => return Err(format!("unsupported channel type: {ct}")), + } let now = unix_now(); if let Err(e) = self .store .upsert(StoredChannel { account_id: account_id.to_string(), - channel_type: "telegram".into(), + channel_type: ct.to_string(), config, created_at: now, updated_at: now, @@ -250,18 +461,49 @@ impl ChannelService for LiveChannelService { .await .map_err(|e| e.to_string())?; - // Read allowlist from current config to tag each sender. - let tg = self.telegram.read().await; - let allowlist: Vec = tg - .account_config(account_id) - .and_then(|cfg| cfg.get("allowlist").cloned()) - .and_then(|v| serde_json::from_value(v).ok()) - .unwrap_or_default(); - - // Query pending OTP challenges for this account. - let otp_challenges = { - let tg_inner = self.telegram.read().await; - tg_inner.pending_otp_challenges(account_id) + let ct = self.resolve_type(account_id).await; + + // Collect allowlist and OTP challenges (serialized to Value for type + // uniformity across Telegram and WhatsApp plugin types). + let (allowlist, otp_challenges): (Vec, Vec) = match ct { + Some(ChannelType::Telegram) => { + if let Some(ref tg_arc) = self.telegram { + let tg = tg_arc.read().await; + let al: Vec = tg + .account_config(account_id) + .and_then(|cfg| cfg.get("allowlist").cloned()) + .and_then(|v| serde_json::from_value(v).ok()) + .unwrap_or_default(); + let otp: Vec = tg + .pending_otp_challenges(account_id) + .into_iter() + .filter_map(|c| serde_json::to_value(c).ok()) + .collect(); + (al, otp) + } else { + (Vec::new(), Vec::new()) + } + }, + #[cfg(feature = "whatsapp")] + Some(ChannelType::Whatsapp) => { + if let Some(ref wa_arc) = self.whatsapp { + let wa = wa_arc.read().await; + let al: Vec = wa + .account_config(account_id) + .and_then(|cfg| cfg.get("allowlist").cloned()) + .and_then(|v| serde_json::from_value(v).ok()) + .unwrap_or_default(); + let otp: Vec = wa + .pending_otp_challenges(account_id) + .into_iter() + .filter_map(|c| serde_json::to_value(c).ok()) + .collect(); + (al, otp) + } else { + (Vec::new(), Vec::new()) + } + }, + _ => (Vec::new(), Vec::new()), }; let list: Vec = senders @@ -282,12 +524,12 @@ impl ChannelService for LiveChannelService { "last_seen": s.last_seen, "allowed": is_allowed, }); - // Attach OTP info if a challenge is pending for this peer. - if let Some(otp) = otp_challenges.iter().find(|c| c.peer_id == s.peer_id) { - entry["otp_pending"] = serde_json::json!({ - "code": otp.code, - "expires_at": otp.expires_at, - }); + if let Some(otp) = otp_challenges.iter().find(|c| { + c.get("peer_id") + .and_then(|v| v.as_str()) + .is_some_and(|pid| pid == s.peer_id) + }) { + entry["otp_pending"] = otp.clone(); } entry }) @@ -307,7 +549,12 @@ impl ChannelService for LiveChannelService { .and_then(|v| v.as_str()) .ok_or_else(|| "missing 'identifier'".to_string())?; - // Read current stored config, add identifier to allowlist, persist & restart. + let ct = self + .resolve_type(account_id) + .await + .ok_or_else(|| format!("unknown account: {account_id}"))?; + + // Update allowlist and persist. let stored = self .store .get(account_id) @@ -334,19 +581,17 @@ impl ChannelService for LiveChannelService { arr.push(serde_json::json!(identifier)); } - // Also ensure dm_policy is set to "allowlist" so the list is enforced. config .as_object_mut() .unwrap() .insert("dm_policy".into(), serde_json::json!("allowlist")); - // Persist. let now = unix_now(); if let Err(e) = self .store .upsert(StoredChannel { account_id: account_id.to_string(), - channel_type: "telegram".into(), + channel_type: ct.to_string(), config: config.clone(), created_at: stored.created_at, updated_at: now, @@ -356,11 +601,27 @@ impl ChannelService for LiveChannelService { warn!(error = %e, account_id, "failed to persist sender approval"); } - // Hot-update the in-memory config (no bot restart, preserves polling - // offset so Telegram doesn't re-deliver the OTP code message). - let tg = self.telegram.read().await; - if let Err(e) = tg.update_account_config(account_id, config) { - warn!(error = %e, account_id, "failed to hot-update config for sender approval"); + // Hot-update the in-memory config for the correct plugin. + match ct { + ChannelType::Telegram => { + if let Some(ref tg_arc) = self.telegram { + let tg = tg_arc.read().await; + if let Err(e) = tg.update_account_config(account_id, config) { + warn!(error = %e, account_id, "failed to hot-update telegram config"); + } + } + }, + #[cfg(feature = "whatsapp")] + ChannelType::Whatsapp => { + if let Some(ref wa_arc) = self.whatsapp { + let wa = wa_arc.read().await; + if let Err(e) = wa.update_account_config(account_id, config) { + warn!(error = %e, account_id, "failed to hot-update whatsapp config"); + } + } + }, + #[allow(unreachable_patterns)] + _ => {}, } info!(account_id, identifier, "sender approved"); @@ -378,6 +639,11 @@ impl ChannelService for LiveChannelService { .and_then(|v| v.as_str()) .ok_or_else(|| "missing 'identifier'".to_string())?; + let ct = self + .resolve_type(account_id) + .await + .ok_or_else(|| format!("unknown account: {account_id}"))?; + let stored = self .store .get(account_id) @@ -395,13 +661,12 @@ impl ChannelService for LiveChannelService { arr.retain(|v| v.as_str().is_none_or(|s| s.to_lowercase() != id_lower)); } - // Persist. let now = unix_now(); if let Err(e) = self .store .upsert(StoredChannel { account_id: account_id.to_string(), - channel_type: "telegram".into(), + channel_type: ct.to_string(), config: config.clone(), created_at: stored.created_at, updated_at: now, @@ -411,13 +676,110 @@ impl ChannelService for LiveChannelService { warn!(error = %e, account_id, "failed to persist sender denial"); } - // Hot-update the in-memory config (no bot restart needed for allowlist removal). - let tg = self.telegram.read().await; - if let Err(e) = tg.update_account_config(account_id, config) { - warn!(error = %e, account_id, "failed to hot-update config for sender denial"); + // Hot-update the in-memory config for the correct plugin. + match ct { + ChannelType::Telegram => { + if let Some(ref tg_arc) = self.telegram { + let tg = tg_arc.read().await; + if let Err(e) = tg.update_account_config(account_id, config) { + warn!(error = %e, account_id, "failed to hot-update telegram config"); + } + } + }, + #[cfg(feature = "whatsapp")] + ChannelType::Whatsapp => { + if let Some(ref wa_arc) = self.whatsapp { + let wa = wa_arc.read().await; + if let Err(e) = wa.update_account_config(account_id, config) { + warn!(error = %e, account_id, "failed to hot-update whatsapp config"); + } + } + }, + #[allow(unreachable_patterns)] + _ => {}, } info!(account_id, identifier, "sender denied"); Ok(serde_json::json!({ "denied": identifier })) } } + +/// Multi-channel outbound that routes send operations to the correct plugin +/// based on the account_id → ChannelType mapping. +pub struct MultiChannelOutbound { + telegram_outbound: Option>, + #[cfg(feature = "whatsapp")] + whatsapp_outbound: Option>, + account_types: Arc>>, +} + +impl MultiChannelOutbound { + pub fn new(account_types: Arc>>) -> Self { + Self { + telegram_outbound: None, + #[cfg(feature = "whatsapp")] + whatsapp_outbound: None, + account_types, + } + } + + pub fn with_telegram(mut self, outbound: Arc) -> Self { + self.telegram_outbound = Some(outbound); + self + } + + #[cfg(feature = "whatsapp")] + pub fn with_whatsapp(mut self, outbound: Arc) -> Self { + self.whatsapp_outbound = Some(outbound); + self + } + + async fn resolve(&self, account_id: &str) -> Option> { + let map = self.account_types.read().await; + match map.get(account_id) { + Some(ChannelType::Telegram) => self.telegram_outbound.clone(), + #[cfg(feature = "whatsapp")] + Some(ChannelType::Whatsapp) => self.whatsapp_outbound.clone(), + _ => self.telegram_outbound.clone(), // default fallback + } + } +} + +#[async_trait] +impl ChannelOutbound for MultiChannelOutbound { + async fn send_text( + &self, + account_id: &str, + to: &str, + text: &str, + reply_to: Option<&str>, + ) -> anyhow::Result<()> { + if let Some(ob) = self.resolve(account_id).await { + ob.send_text(account_id, to, text, reply_to).await + } else { + Err(anyhow::anyhow!("no outbound for account: {account_id}")) + } + } + + async fn send_media( + &self, + account_id: &str, + to: &str, + payload: &moltis_common::types::ReplyPayload, + reply_to: Option<&str>, + ) -> anyhow::Result<()> { + if let Some(ob) = self.resolve(account_id).await { + ob.send_media(account_id, to, payload, reply_to).await + } else { + Err(anyhow::anyhow!("no outbound for account: {account_id}")) + } + } + + async fn send_typing(&self, account_id: &str, to: &str) -> anyhow::Result<()> { + if let Some(ob) = self.resolve(account_id).await { + ob.send_typing(account_id, to).await + } else { + Ok(()) // typing is best-effort + } + } +} diff --git a/crates/gateway/src/channel_events.rs b/crates/gateway/src/channel_events.rs index afc73bd5..fedf0c18 100644 --- a/crates/gateway/src/channel_events.rs +++ b/crates/gateway/src/channel_events.rs @@ -139,7 +139,10 @@ impl ChannelEventSink for GatewayChannelEventSink { .await; let n = existing.len() + 1; let _ = session_meta - .upsert(&session_key, Some(format!("Telegram {n}"))) + .upsert( + &session_key, + Some(format!("{} {n}", reply_to.channel_type.display_name())), + ) .await; } session_meta @@ -591,7 +594,10 @@ impl ChannelEventSink for GatewayChannelEventSink { .await; let n = existing.len() + 1; let _ = session_meta - .upsert(&session_key, Some(format!("Telegram {n}"))) + .upsert( + &session_key, + Some(format!("{} {n}", reply_to.channel_type.display_name())), + ) .await; } session_meta @@ -771,7 +777,10 @@ impl ChannelEventSink for GatewayChannelEventSink { // Create the new session entry with channel binding. session_metadata - .upsert(&new_key, Some(format!("Telegram {n}"))) + .upsert( + &new_key, + Some(format!("{} {n}", reply_to.channel_type.display_name())), + ) .await .map_err(|e| anyhow!("failed to create session: {e}"))?; session_metadata diff --git a/crates/gateway/src/chat.rs b/crates/gateway/src/chat.rs index 3fed8938..ff1b92e1 100644 --- a/crates/gateway/src/chat.rs +++ b/crates/gateway/src/chat.rs @@ -4210,31 +4210,33 @@ async fn deliver_channel_replies_to_targets( }; let reply_to = target.message_id.as_deref(); match target.channel_type { - moltis_channels::ChannelType::Telegram => match tts_payload { - Some(payload) => { - if let Err(e) = outbound - .send_media(&target.account_id, &target.chat_id, &payload, reply_to) - .await - { - warn!( - account_id = target.account_id, - chat_id = target.chat_id, - "failed to send channel voice reply: {e}" - ); - } - }, - None => { - if let Err(e) = outbound - .send_text(&target.account_id, &target.chat_id, &text, reply_to) - .await - { - warn!( - account_id = target.account_id, - chat_id = target.chat_id, - "failed to send channel reply: {e}" - ); - } - }, + moltis_channels::ChannelType::Telegram | moltis_channels::ChannelType::Whatsapp => { + match tts_payload { + Some(payload) => { + if let Err(e) = outbound + .send_media(&target.account_id, &target.chat_id, &payload, reply_to) + .await + { + warn!( + account_id = target.account_id, + chat_id = target.chat_id, + "failed to send channel voice reply: {e}" + ); + } + }, + None => { + if let Err(e) = outbound + .send_text(&target.account_id, &target.chat_id, &text, reply_to) + .await + { + warn!( + account_id = target.account_id, + chat_id = target.chat_id, + "failed to send channel reply: {e}" + ); + } + }, + } }, } })); @@ -4568,7 +4570,7 @@ async fn send_screenshot_to_channels( let payload = payload.clone(); tasks.push(tokio::spawn(async move { match target.channel_type { - moltis_channels::ChannelType::Telegram => { + moltis_channels::ChannelType::Telegram | moltis_channels::ChannelType::Whatsapp => { let reply_to = target.message_id.as_deref(); if let Err(e) = outbound .send_media(&target.account_id, &target.chat_id, &payload, reply_to) @@ -4579,8 +4581,7 @@ async fn send_screenshot_to_channels( chat_id = target.chat_id, "failed to send screenshot to channel: {e}" ); - // Notify the user of the error - let error_msg = format!("⚠️ Failed to send screenshot: {e}"); + let error_msg = format!("Failed to send screenshot: {e}"); let _ = outbound .send_text(&target.account_id, &target.chat_id, &error_msg, reply_to) .await; @@ -4588,7 +4589,7 @@ async fn send_screenshot_to_channels( debug!( account_id = target.account_id, chat_id = target.chat_id, - "sent screenshot to telegram" + "sent screenshot to channel" ); } }, diff --git a/crates/gateway/src/server.rs b/crates/gateway/src/server.rs index 9c0c7ef0..ff265f46 100644 --- a/crates/gateway/src/server.rs +++ b/crates/gateway/src/server.rs @@ -29,7 +29,7 @@ use { #[cfg(feature = "web-ui")] use axum::{extract::Path, http::StatusCode}; -use {moltis_channels::ChannelPlugin, moltis_protocol::TICK_INTERVAL_MS}; +use moltis_protocol::TICK_INTERVAL_MS; use moltis_agents::providers::ProviderRegistry; @@ -1558,9 +1558,9 @@ pub async fn start_gateway( // Session service is wired after hook registry is built (below). - // Wire channel store and Telegram channel service. + // Wire channel store and multi-channel service. { - use moltis_channels::store::ChannelStore; + use moltis_channels::{ChannelPlugin, store::ChannelStore}; let channel_store: Arc = Arc::new( crate::channel_store::SqliteChannelStore::new(db_pool.clone()), @@ -1569,11 +1569,21 @@ pub async fn start_gateway( let channel_sink = Arc::new(crate::channel_events::GatewayChannelEventSink::new( Arc::clone(&deferred_state), )); + + let mut channel_service = crate::channel::LiveChannelService::new( + Arc::clone(&channel_store), + Arc::clone(&message_log), + Arc::clone(&session_metadata), + ); + + // ── Telegram plugin ────────────────────────────────────────────── let mut tg_plugin = moltis_telegram::TelegramPlugin::new() .with_message_log(Arc::clone(&message_log)) - .with_event_sink(channel_sink); + .with_event_sink( + Arc::clone(&channel_sink) as Arc + ); - // Start channels from config file (these take precedence). + // Start Telegram channels from config file (these take precedence). let tg_accounts = &config.channels.telegram; let mut started: std::collections::HashSet = std::collections::HashSet::new(); for (account_id, account_config) in tg_accounts { @@ -1587,7 +1597,40 @@ pub async fn start_gateway( } } - // Load persisted channels that weren't in the config file. + let tg_outbound = tg_plugin.shared_outbound(); + + // ── WhatsApp plugin ────────────────────────────────────────────── + #[cfg(feature = "whatsapp")] + let wa_outbound = { + let wa_data_dir = data_dir.join("whatsapp"); + if let Err(e) = std::fs::create_dir_all(&wa_data_dir) { + tracing::warn!("failed to create whatsapp data dir: {e}"); + } + let mut wa_plugin = moltis_whatsapp::WhatsAppPlugin::new(wa_data_dir) + .with_message_log(Arc::clone(&message_log)) + .with_event_sink( + Arc::clone(&channel_sink) as Arc + ); + + // Start WhatsApp channels from config file. + let wa_accounts = &config.channels.whatsapp; + for (account_id, account_config) in wa_accounts { + if let Err(e) = wa_plugin + .start_account(account_id, account_config.clone()) + .await + { + tracing::warn!(account_id, "failed to start whatsapp account: {e}"); + } else { + started.insert(account_id.clone()); + } + } + + let wa_ob = wa_plugin.shared_outbound(); + channel_service.register_whatsapp(wa_plugin); + wa_ob + }; + + // ── Load persisted channels from DB ────────────────────────────── match channel_store.list().await { Ok(stored) => { info!("{} stored channel(s) found in database", stored.len()); @@ -1604,13 +1647,34 @@ pub async fn start_gateway( channel_type = ch.channel_type, "starting stored channel" ); - if let Err(e) = tg_plugin.start_account(&ch.account_id, ch.config).await { - tracing::warn!( - account_id = ch.account_id, - "failed to start stored telegram account: {e}" - ); - } else { - started.insert(ch.account_id); + match ch.channel_type.as_str() { + "telegram" => { + if let Err(e) = tg_plugin.start_account(&ch.account_id, ch.config).await + { + tracing::warn!( + account_id = ch.account_id, + "failed to start stored telegram account: {e}" + ); + } else { + started.insert(ch.account_id); + } + }, + #[cfg(feature = "whatsapp")] + "whatsapp" => { + // WhatsApp stored channels are started via the registered plugin + // through the channel service add() method at runtime. + tracing::info!( + account_id = ch.account_id, + "whatsapp stored channel will be started via service" + ); + }, + other => { + tracing::warn!( + account_id = ch.account_id, + channel_type = other, + "unknown stored channel type, skipping" + ); + }, } } }, @@ -1620,19 +1684,23 @@ pub async fn start_gateway( } if !started.is_empty() { - info!("{} telegram account(s) started", started.len()); + info!("{} channel account(s) started", started.len()); } - // Grab shared outbound before moving tg_plugin into the channel service. - let tg_outbound = tg_plugin.shared_outbound(); - services = services.with_channel_outbound(tg_outbound); + // Register Telegram plugin (must happen after starting accounts, + // before moving tg_plugin). + channel_service.register_telegram(tg_plugin); - services.channel = Arc::new(crate::channel::LiveChannelService::new( - tg_plugin, - channel_store, - Arc::clone(&message_log), - Arc::clone(&session_metadata), - )); + // Build multi-channel outbound. + let multi_outbound = + crate::channel::MultiChannelOutbound::new(channel_service.account_types()) + .with_telegram(tg_outbound); + + #[cfg(feature = "whatsapp")] + let multi_outbound = multi_outbound.with_whatsapp(wa_outbound); + + services = services.with_channel_outbound(Arc::new(multi_outbound)); + services.channel = Arc::new(channel_service); } services = services.with_session_metadata(Arc::clone(&session_metadata)); diff --git a/crates/whatsapp/Cargo.toml b/crates/whatsapp/Cargo.toml new file mode 100644 index 00000000..6f5be0cc --- /dev/null +++ b/crates/whatsapp/Cargo.toml @@ -0,0 +1,39 @@ +[package] +edition.workspace = true +name = "moltis-whatsapp" +version.workspace = true + +[dependencies] +anyhow = { workspace = true } +async-trait = { workspace = true } +dashmap = { workspace = true } +moltis-channels = { workspace = true } +moltis-common = { workspace = true } +moltis-config = { workspace = true } +moltis-media = { workspace = true } +moltis-metrics = { optional = true, workspace = true } +rand = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sled = { workspace = true } +tokio = { workspace = true } +tokio-util = { workspace = true } +tracing = { workspace = true } +wacore = { workspace = true } +wacore-binary = { workspace = true } +waproto = { workspace = true } + +whatsapp-rust = { workspace = true } +whatsapp-rust-tokio-transport = { workspace = true } +whatsapp-rust-ureq-http-client = { workspace = true } + +[features] +default = [] +metrics = ["dep:moltis-metrics"] + +[dev-dependencies] +tempfile = { workspace = true } +tokio = { workspace = true } + +[lints] +workspace = true diff --git a/crates/whatsapp/src/access.rs b/crates/whatsapp/src/access.rs new file mode 100644 index 00000000..15646c83 --- /dev/null +++ b/crates/whatsapp/src/access.rs @@ -0,0 +1,230 @@ +use moltis_channels::gating::{self, DmPolicy, GroupPolicy}; + +use crate::config::WhatsAppAccountConfig; + +/// Determine if an inbound WhatsApp message should be processed. +/// +/// Returns `Ok(())` if the message is allowed, or `Err(reason)` if it should +/// be denied. WhatsApp does not have @mention semantics like Telegram bots, +/// so there is no `MentionMode` gating. +pub fn check_access( + config: &WhatsAppAccountConfig, + is_group: bool, + peer_id: &str, + username: Option<&str>, + group_id: Option<&str>, +) -> Result<(), AccessDenied> { + if is_group { + check_group_access(config, group_id) + } else { + check_dm_access(config, peer_id, username) + } +} + +fn check_dm_access( + config: &WhatsAppAccountConfig, + peer_id: &str, + username: Option<&str>, +) -> Result<(), AccessDenied> { + match config.dm_policy { + DmPolicy::Disabled => Err(AccessDenied::DmsDisabled), + DmPolicy::Open => Ok(()), + DmPolicy::Allowlist => { + // An empty allowlist with an explicit Allowlist policy means + // "deny everyone" — not "allow everyone". + if config.allowlist.is_empty() { + return Err(AccessDenied::NotOnAllowlist); + } + if gating::is_allowed(peer_id, &config.allowlist) + || username.is_some_and(|u| gating::is_allowed(u, &config.allowlist)) + { + Ok(()) + } else { + Err(AccessDenied::NotOnAllowlist) + } + }, + } +} + +fn check_group_access( + config: &WhatsAppAccountConfig, + group_id: Option<&str>, +) -> Result<(), AccessDenied> { + match config.group_policy { + GroupPolicy::Disabled => Err(AccessDenied::GroupsDisabled), + GroupPolicy::Allowlist => { + let gid = group_id.unwrap_or(""); + if config.group_allowlist.is_empty() + || !gating::is_allowed(gid, &config.group_allowlist) + { + Err(AccessDenied::GroupNotOnAllowlist) + } else { + Ok(()) + } + }, + GroupPolicy::Open => Ok(()), + } +} + +/// Reason an inbound message was denied. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AccessDenied { + DmsDisabled, + NotOnAllowlist, + GroupsDisabled, + GroupNotOnAllowlist, +} + +impl std::fmt::Display for AccessDenied { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::DmsDisabled => write!(f, "DMs are disabled"), + Self::NotOnAllowlist => write!(f, "user not on allowlist"), + Self::GroupsDisabled => write!(f, "groups are disabled"), + Self::GroupNotOnAllowlist => write!(f, "group not on allowlist"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg() -> WhatsAppAccountConfig { + WhatsAppAccountConfig::default() + } + + #[test] + fn open_dm_allows_all() { + let c = cfg(); + assert!(check_access(&c, false, "anyone", None, None).is_ok()); + } + + #[test] + fn disabled_dm_rejects() { + let mut c = cfg(); + c.dm_policy = DmPolicy::Disabled; + assert_eq!( + check_access(&c, false, "user", None, None), + Err(AccessDenied::DmsDisabled) + ); + } + + #[test] + fn allowlist_dm_by_peer_id() { + let mut c = cfg(); + c.dm_policy = DmPolicy::Allowlist; + c.allowlist = vec!["15551234567".into()]; + assert!(check_access(&c, false, "15551234567", None, None).is_ok()); + assert_eq!( + check_access(&c, false, "15559876543", None, None), + Err(AccessDenied::NotOnAllowlist) + ); + } + + #[test] + fn allowlist_dm_by_username() { + let mut c = cfg(); + c.dm_policy = DmPolicy::Allowlist; + c.allowlist = vec!["alice".into()]; + // JID peer_id doesn't match, but username does + assert!(check_access(&c, false, "15551234567@s.whatsapp.net", Some("alice"), None).is_ok()); + // Neither matches + assert_eq!( + check_access(&c, false, "15551234567@s.whatsapp.net", Some("bob"), None), + Err(AccessDenied::NotOnAllowlist) + ); + } + + #[test] + fn group_open_allows_all() { + let c = cfg(); + assert!(check_access(&c, true, "user", None, Some("group1")).is_ok()); + } + + #[test] + fn group_disabled_rejects() { + let mut c = cfg(); + c.group_policy = GroupPolicy::Disabled; + assert_eq!( + check_access(&c, true, "user", None, Some("group1")), + Err(AccessDenied::GroupsDisabled) + ); + } + + #[test] + fn group_allowlist() { + let mut c = cfg(); + c.group_policy = GroupPolicy::Allowlist; + c.group_allowlist = vec!["grp1".into()]; + assert!(check_access(&c, true, "user", None, Some("grp1")).is_ok()); + assert_eq!( + check_access(&c, true, "user", None, Some("grp2")), + Err(AccessDenied::GroupNotOnAllowlist) + ); + } + + #[test] + fn empty_dm_allowlist_denies_all() { + let mut c = cfg(); + c.dm_policy = DmPolicy::Allowlist; + assert_eq!( + check_access(&c, false, "anyone", None, None), + Err(AccessDenied::NotOnAllowlist) + ); + assert_eq!( + check_access(&c, false, "anyone", Some("user"), None), + Err(AccessDenied::NotOnAllowlist) + ); + } + + #[test] + fn empty_group_allowlist_denies_all() { + let mut c = cfg(); + c.group_policy = GroupPolicy::Allowlist; + assert_eq!( + check_access(&c, true, "user", None, Some("grp1")), + Err(AccessDenied::GroupNotOnAllowlist) + ); + } + + /// Security regression: removing the last entry from an allowlist must + /// NOT silently switch to open access. + #[test] + fn security_removing_last_allowlist_entry_denies_access() { + // --- DM --- + let mut c = cfg(); + c.dm_policy = DmPolicy::Allowlist; + c.allowlist = vec!["15551234567".into()]; + + assert!(check_access(&c, false, "15551234567", Some("alice"), None).is_ok()); + + c.allowlist.clear(); + + assert_eq!( + check_access(&c, false, "15551234567", None, None), + Err(AccessDenied::NotOnAllowlist), + "empty DM allowlist must deny by peer_id" + ); + assert_eq!( + check_access(&c, false, "15551234567", Some("alice"), None), + Err(AccessDenied::NotOnAllowlist), + "empty DM allowlist must deny by username" + ); + + // --- Group --- + let mut g = cfg(); + g.group_policy = GroupPolicy::Allowlist; + g.group_allowlist = vec!["grp1".into()]; + + assert!(check_access(&g, true, "user", None, Some("grp1")).is_ok()); + + g.group_allowlist.clear(); + + assert_eq!( + check_access(&g, true, "user", None, Some("grp1")), + Err(AccessDenied::GroupNotOnAllowlist), + "empty group allowlist must deny previously-allowed group" + ); + } +} diff --git a/crates/whatsapp/src/config.rs b/crates/whatsapp/src/config.rs new file mode 100644 index 00000000..ffb1e26e --- /dev/null +++ b/crates/whatsapp/src/config.rs @@ -0,0 +1,158 @@ +use { + moltis_channels::gating::{DmPolicy, GroupPolicy}, + serde::{Deserialize, Serialize}, + std::path::PathBuf, +}; + +/// Configuration for a single WhatsApp account. +/// +/// Unlike Telegram, WhatsApp uses Linked Devices (QR code pairing) so no +/// bot token is needed. The Signal Protocol session state is persisted in a +/// per-account store. +#[derive(Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct WhatsAppAccountConfig { + /// Path to the store for this account's Signal Protocol sessions. + /// Defaults to `/whatsapp//`. + #[serde(skip_serializing_if = "Option::is_none")] + pub store_path: Option, + + /// Display name populated after successful pairing. + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + + /// Phone number populated after successful pairing. + #[serde(skip_serializing_if = "Option::is_none")] + pub phone_number: Option, + + /// Whether this account has been paired (QR code scanned). + pub paired: bool, + + /// Default model ID for this account's sessions. + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + + /// Provider name associated with `model`. + #[serde(skip_serializing_if = "Option::is_none")] + pub model_provider: Option, + + /// DM access policy. + pub dm_policy: DmPolicy, + + /// Group access policy. + pub group_policy: GroupPolicy, + + /// User/peer allowlist for DMs (JID user parts or phone numbers). + pub allowlist: Vec, + + /// Group JID allowlist. + pub group_allowlist: Vec, + + /// Enable OTP self-approval for non-allowlisted DM users (default: true). + pub otp_self_approval: bool, + + /// Cooldown in seconds after 3 failed OTP attempts (default: 300). + pub otp_cooldown_secs: u64, +} + +impl std::fmt::Debug for WhatsAppAccountConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WhatsAppAccountConfig") + .field("paired", &self.paired) + .field("display_name", &self.display_name) + .field("dm_policy", &self.dm_policy) + .field("group_policy", &self.group_policy) + .finish_non_exhaustive() + } +} + +impl Default for WhatsAppAccountConfig { + fn default() -> Self { + Self { + store_path: None, + display_name: None, + phone_number: None, + paired: false, + model: None, + model_provider: None, + dm_policy: DmPolicy::default(), + group_policy: GroupPolicy::default(), + allowlist: Vec::new(), + group_allowlist: Vec::new(), + otp_self_approval: true, + otp_cooldown_secs: 300, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_config() { + let cfg = WhatsAppAccountConfig::default(); + assert!(!cfg.paired); + assert!(cfg.store_path.is_none()); + assert!(cfg.display_name.is_none()); + assert!(cfg.model.is_none()); + assert_eq!(cfg.dm_policy, DmPolicy::Open); + assert_eq!(cfg.group_policy, GroupPolicy::Open); + assert!(cfg.allowlist.is_empty()); + assert!(cfg.group_allowlist.is_empty()); + assert!(cfg.otp_self_approval); + assert_eq!(cfg.otp_cooldown_secs, 300); + } + + #[test] + fn deserialize_from_json() { + let json = r#"{ + "paired": true, + "display_name": "My Phone", + "phone_number": "+15551234567" + }"#; + let cfg: WhatsAppAccountConfig = serde_json::from_str(json).unwrap(); + assert!(cfg.paired); + assert_eq!(cfg.display_name.as_deref(), Some("My Phone")); + assert_eq!(cfg.phone_number.as_deref(), Some("+15551234567")); + // Defaults for access control fields + assert_eq!(cfg.dm_policy, DmPolicy::Open); + assert!(cfg.allowlist.is_empty()); + } + + #[test] + fn deserialize_with_access_control() { + let json = r#"{ + "dm_policy": "allowlist", + "group_policy": "disabled", + "allowlist": ["user1", "user2"], + "group_allowlist": ["group1"], + "otp_self_approval": false, + "otp_cooldown_secs": 600 + }"#; + let cfg: WhatsAppAccountConfig = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.dm_policy, DmPolicy::Allowlist); + assert_eq!(cfg.group_policy, GroupPolicy::Disabled); + assert_eq!(cfg.allowlist, vec!["user1", "user2"]); + assert_eq!(cfg.group_allowlist, vec!["group1"]); + assert!(!cfg.otp_self_approval); + assert_eq!(cfg.otp_cooldown_secs, 600); + } + + #[test] + fn serialize_roundtrip() { + let cfg = WhatsAppAccountConfig { + paired: true, + display_name: Some("Test".into()), + dm_policy: DmPolicy::Allowlist, + allowlist: vec!["alice".into()], + ..Default::default() + }; + let json = serde_json::to_string(&cfg).unwrap(); + let cfg2: WhatsAppAccountConfig = serde_json::from_str(&json).unwrap(); + assert!(cfg2.paired); + assert_eq!(cfg2.display_name.as_deref(), Some("Test")); + assert_eq!(cfg2.dm_policy, DmPolicy::Allowlist); + assert_eq!(cfg2.allowlist, vec!["alice"]); + } +} diff --git a/crates/whatsapp/src/connection.rs b/crates/whatsapp/src/connection.rs new file mode 100644 index 00000000..7ceaf79f --- /dev/null +++ b/crates/whatsapp/src/connection.rs @@ -0,0 +1,142 @@ +use std::sync::Arc; + +use { + anyhow::Result, + tokio_util::sync::CancellationToken, + tracing::{info, warn}, +}; + +use moltis_channels::{ChannelEventSink, message_log::MessageLog}; + +use crate::{ + config::WhatsAppAccountConfig, + handlers, + state::{AccountState, AccountStateMap}, +}; + +/// Start a WhatsApp connection for the given account. +/// +/// Builds the `Bot` with a persistent sled store, registers the event handler, +/// and spawns the event loop as a background tokio task. Session state persists +/// across restarts so the user does not need to re-scan the QR code. +pub async fn start_connection( + account_id: String, + config: WhatsAppAccountConfig, + accounts: AccountStateMap, + data_dir: std::path::PathBuf, + message_log: Option>, + event_sink: Option>, +) -> Result<()> { + // Use persistent sled store at /whatsapp//. + let store_path = config + .store_path + .clone() + .unwrap_or_else(|| data_dir.join("whatsapp").join(&account_id)); + + info!(account_id = %account_id, path = %store_path.display(), "opening sled WhatsApp store"); + + let backend = Arc::new( + crate::sled_store::SledStore::open(&store_path).map_err(|e| { + anyhow::anyhow!("failed to open sled store at {}: {e}", store_path.display()) + })?, + ); + + let cancel = CancellationToken::new(); + let cancel_clone = cancel.clone(); + + // Build the bot. + let account_id_clone = account_id.clone(); + let event_sink_clone = event_sink.clone(); + let message_log_clone = message_log.clone(); + + // We need to create a temporary accounts ref for the state that will be + // populated after bot.build(). + let state_ref: Arc>> = + Arc::new(tokio::sync::OnceCell::new()); + let state_ref_handler = Arc::clone(&state_ref); + let accounts_handler = Arc::clone(&accounts); + + let mut bot = whatsapp_rust::bot::Bot::builder() + .with_backend(backend) + .with_transport_factory( + whatsapp_rust_tokio_transport::TokioWebSocketTransportFactory::new(), + ) + .with_http_client(whatsapp_rust_ureq_http_client::UreqHttpClient::new()) + .on_event(move |event, client| { + let state_ref = Arc::clone(&state_ref_handler); + let accounts = Arc::clone(&accounts_handler); + async move { + if let Some(state) = state_ref.get() { + handlers::handle_event(event, client, Arc::clone(state), accounts).await; + } + } + }) + .build() + .await?; + + let client = bot.client(); + + // Create account state. + let otp_cooldown = config.otp_cooldown_secs; + let account_state = Arc::new(AccountState { + client: Arc::clone(&client), + account_id: account_id_clone.clone(), + config, + cancel: cancel_clone, + message_log: message_log_clone, + event_sink: event_sink_clone, + latest_qr: std::sync::RwLock::new(None), + connected: std::sync::atomic::AtomicBool::new(false), + otp: std::sync::Mutex::new(crate::otp::OtpState::new(otp_cooldown)), + recent_sent_ids: std::sync::Mutex::new(std::collections::VecDeque::new()), + }); + + // Populate the OnceCell so the event handler can access state. + let _ = state_ref.set(Arc::clone(&account_state)); + + // Insert into the shared map. + { + let mut map = accounts.write().unwrap(); + map.insert(account_id.clone(), AccountState { + client: Arc::clone(&client), + account_id: account_id.clone(), + config: account_state.config.clone(), + cancel: cancel.clone(), + message_log: account_state.message_log.clone(), + event_sink: account_state.event_sink.clone(), + latest_qr: std::sync::RwLock::new(None), + connected: std::sync::atomic::AtomicBool::new(false), + otp: std::sync::Mutex::new(crate::otp::OtpState::new(otp_cooldown)), + recent_sent_ids: std::sync::Mutex::new(std::collections::VecDeque::new()), + }); + } + + // Spawn the event loop. + let account_id_task = account_id.clone(); + tokio::spawn(async move { + tokio::select! { + result = bot.run() => { + match result { + Ok(handle) => { + tokio::select! { + _ = handle => { + info!(account_id = %account_id_task, "WhatsApp bot task completed"); + }, + _ = cancel.cancelled() => { + info!(account_id = %account_id_task, "WhatsApp account cancelled"); + }, + } + }, + Err(e) => { + warn!(account_id = %account_id_task, error = %e, "WhatsApp bot failed to start"); + }, + } + }, + _ = cancel.cancelled() => { + info!(account_id = %account_id_task, "WhatsApp account cancelled before start"); + }, + } + }); + + Ok(()) +} diff --git a/crates/whatsapp/src/handlers.rs b/crates/whatsapp/src/handlers.rs new file mode 100644 index 00000000..7c5e5aaa --- /dev/null +++ b/crates/whatsapp/src/handlers.rs @@ -0,0 +1,854 @@ +use std::sync::Arc; + +use { + tracing::{debug, info, warn}, + wacore::types::{events::Event, message::MessageInfo}, + wacore_binary::jid::{Jid, JidExt as _}, + waproto::whatsapp as wa, + whatsapp_rust::client::Client, +}; + +use moltis_channels::{ + ChannelAttachment, ChannelEvent, ChannelMessageKind, ChannelMessageMeta, ChannelReplyTarget, + ChannelType, message_log::MessageLogEntry, +}; + +use crate::{ + access::{self, AccessDenied}, + otp::{OTP_CHALLENGE_MSG, OtpInitResult, OtpVerifyResult}, + state::{AccountState, AccountStateMap, has_bot_watermark}, +}; + +/// Process an incoming whatsapp-rust event for the given account. +pub async fn handle_event( + event: Event, + client: Arc, + state: Arc, + accounts: AccountStateMap, +) { + match event { + Event::PairingQrCode { code, .. } => { + info!(account_id = %state.account_id, "QR code generated for pairing"); + + // Store latest QR data so the REST endpoint can serve it. + if let Ok(mut qr) = state.latest_qr.write() { + *qr = Some(code.clone()); + } + + if let Some(ref sink) = state.event_sink { + sink.emit(ChannelEvent::PairingQrCode { + channel_type: ChannelType::Whatsapp, + account_id: state.account_id.clone(), + qr_data: code, + }) + .await; + } + }, + Event::Connected(_) => { + info!(account_id = %state.account_id, "WhatsApp connected"); + state + .connected + .store(true, std::sync::atomic::Ordering::Relaxed); + + // Clear QR data once connected. + if let Ok(mut qr) = state.latest_qr.write() { + *qr = None; + } + + let display_name = state.client.get_push_name().await; + let display = if display_name.is_empty() { + None + } else { + Some(display_name) + }; + + if let Some(ref sink) = state.event_sink { + sink.emit(ChannelEvent::PairingComplete { + channel_type: ChannelType::Whatsapp, + account_id: state.account_id.clone(), + display_name: display, + }) + .await; + } + }, + Event::PairError(err) => { + warn!(account_id = %state.account_id, error = ?err, "WhatsApp pairing failed"); + if let Some(ref sink) = state.event_sink { + sink.emit(ChannelEvent::PairingFailed { + channel_type: ChannelType::Whatsapp, + account_id: state.account_id.clone(), + reason: format!("{err:?}"), + }) + .await; + } + }, + Event::Disconnected(_) => { + info!(account_id = %state.account_id, "WhatsApp disconnected"); + state + .connected + .store(false, std::sync::atomic::Ordering::Relaxed); + }, + Event::LoggedOut(_) => { + warn!(account_id = %state.account_id, "WhatsApp logged out"); + state + .connected + .store(false, std::sync::atomic::Ordering::Relaxed); + if let Some(ref sink) = state.event_sink { + sink.emit(ChannelEvent::AccountDisabled { + channel_type: ChannelType::Whatsapp, + account_id: state.account_id.clone(), + reason: "logged out by WhatsApp".into(), + }) + .await; + } + }, + Event::Message(msg, msg_info) => { + handle_message(msg, msg_info, &client, &state, &accounts).await; + }, + _ => { + debug!(account_id = %state.account_id, event = ?std::mem::discriminant(&event), "unhandled WhatsApp event"); + }, + } +} + +async fn handle_message( + msg: Box, + info: MessageInfo, + client: &Client, + state: &AccountState, + accounts: &AccountStateMap, +) { + let sender_jid: &Jid = &info.source.sender; + let chat_jid: &Jid = &info.source.chat; + + let peer_id = sender_jid.to_string(); + let chat_id = chat_jid.to_string(); + let username = sender_jid.user.clone(); + let sender_name = if info.push_name.is_empty() { + None + } else { + Some(info.push_name.clone()) + }; + + // Self-chat detection: when `is_from_me` is true, the message was sent from + // another device on our own WhatsApp account (phone, WhatsApp Web, etc.). + // We allow these through only for self-chat (user messaging themselves) and + // only if the message wasn't sent by the bot itself (loop prevention). + // + // Verified self-chat messages bypass access control (the account owner is + // always allowed), so this flag is used later to skip the access check. + let mut is_owner_self_chat = false; + if info.source.is_from_me { + let own_pn = state.client.get_pn().await; + let own_lid = state.client.get_lid().await; + let is_self_chat = own_pn + .as_ref() + .is_some_and(|pn| pn.is_same_user_as(chat_jid)) + || own_lid + .as_ref() + .is_some_and(|lid| lid.is_same_user_as(chat_jid)); + + // Check text for bot watermark as secondary loop detection. + let raw_text = msg + .conversation + .as_deref() + .or_else(|| { + msg.extended_text_message + .as_ref() + .and_then(|m| m.text.as_deref()) + }) + .unwrap_or(""); + + if !is_self_chat || state.was_sent_by_us(&info.id) || has_bot_watermark(raw_text) { + debug!(account_id = %state.account_id, is_self_chat, "ignoring self-sent message"); + return; + } + debug!(account_id = %state.account_id, "processing self-chat message from another device"); + is_owner_self_chat = true; + } + + // Extract text from the message. + let text = msg + .conversation + .as_deref() + .or_else(|| { + msg.extended_text_message + .as_ref() + .and_then(|m| m.text.as_deref()) + }) + .unwrap_or(""); + + let message_kind = classify_message(&msg, text); + + // Access control. Self-chat messages from the account owner always bypass + // access control — the owner is inherently authorized. + let is_group = info.source.is_group; + let group_id = if is_group { + Some(chat_id.as_str()) + } else { + None + }; + let access_result = if is_owner_self_chat { + Ok(()) + } else { + access::check_access(&state.config, is_group, &peer_id, Some(&username), group_id) + }; + let access_granted = access_result.is_ok(); + + // Log the message (with access_granted reflecting the actual check). + if let Some(ref log) = state.message_log { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + let entry = MessageLogEntry { + id: 0, + account_id: state.account_id.clone(), + channel_type: ChannelType::Whatsapp.to_string(), + peer_id: peer_id.clone(), + username: Some(username.clone()), + sender_name: sender_name.clone(), + chat_id: chat_id.clone(), + chat_type: if is_group { + "group" + } else { + "private" + } + .into(), + body: text.to_string(), + access_granted, + created_at: now, + }; + if let Err(e) = log.log(entry).await { + warn!(account_id = %state.account_id, "failed to log message: {e}"); + } + } + + // Emit inbound message event. + if let Some(ref sink) = state.event_sink { + sink.emit(ChannelEvent::InboundMessage { + channel_type: ChannelType::Whatsapp, + account_id: state.account_id.clone(), + peer_id: peer_id.clone(), + username: Some(username.clone()), + sender_name: sender_name.clone(), + message_count: None, + access_granted, + }) + .await; + } + + // Handle access denial. + if let Err(reason) = access_result { + warn!( + account_id = %state.account_id, + %reason, + peer_id, + username, + "access denied" + ); + + // OTP self-approval for non-allowlisted DM users. + if reason == AccessDenied::NotOnAllowlist && !is_group && state.config.otp_self_approval { + handle_otp_flow( + accounts, + &state.account_id, + &peer_id, + Some(&username), + sender_name.as_deref(), + text, + chat_jid, + state, + ) + .await; + } + return; + } + + // Check for slash commands. + if let Some(cmd) = text.strip_prefix('/') { + let reply_to = ChannelReplyTarget { + channel_type: ChannelType::Whatsapp, + account_id: state.account_id.clone(), + chat_id: chat_id.clone(), + message_id: Some(info.id.to_string()), + }; + if let Some(ref sink) = state.event_sink { + match sink.dispatch_command(cmd, reply_to).await { + Ok(response) => { + let outbound_msg = wa::Message { + conversation: Some(response), + ..Default::default() + }; + if let Err(e) = state.send_message(chat_jid.clone(), outbound_msg).await { + warn!(error = %e, "failed to send command response"); + } + }, + Err(e) => { + let error_msg = wa::Message { + conversation: Some(format!("Error: {e}")), + ..Default::default() + }; + let _ = state.send_message(chat_jid.clone(), error_msg).await; + }, + } + } + return; + } + + let account_id = &state.account_id; + let reply_to = ChannelReplyTarget { + channel_type: ChannelType::Whatsapp, + account_id: state.account_id.clone(), + chat_id: chat_id.clone(), + message_id: Some(info.id.to_string()), + }; + let meta = ChannelMessageMeta { + channel_type: ChannelType::Whatsapp, + sender_name, + username: Some(username), + message_kind: Some(message_kind), + model: state.config.model.clone(), + }; + + // Dispatch based on message kind. + match message_kind { + ChannelMessageKind::Text => { + if let Some(ref sink) = state.event_sink { + sink.dispatch_to_chat(text, reply_to, meta).await; + } + }, + ChannelMessageKind::Photo => { + handle_photo(&msg, client, account_id, reply_to, meta, chat_jid, state).await; + }, + ChannelMessageKind::Voice | ChannelMessageKind::Audio => { + handle_voice_audio( + &msg, + client, + account_id, + message_kind, + reply_to, + meta, + chat_jid, + state, + ) + .await; + }, + ChannelMessageKind::Video => { + handle_video(&msg, client, account_id, reply_to, meta, chat_jid, state).await; + }, + ChannelMessageKind::Document => { + handle_document(&msg, client, account_id, reply_to, meta, chat_jid, state).await; + }, + ChannelMessageKind::Location => { + handle_location(&msg, account_id, reply_to, meta, chat_jid, state).await; + }, + ChannelMessageKind::Other => { + let reply_msg = wa::Message { + conversation: Some( + "Sorry, I can't understand that message type. Check logs for details.".into(), + ), + ..Default::default() + }; + let _ = state.send_message(chat_jid.clone(), reply_msg).await; + }, + } +} + +// ============================================================================ +// Media handlers +// ============================================================================ + +/// Handle an inbound photo/image message: download, optimize, dispatch with attachment. +#[allow(clippy::too_many_arguments)] +async fn handle_photo( + msg: &wa::Message, + client: &Client, + account_id: &str, + reply_to: ChannelReplyTarget, + meta: ChannelMessageMeta, + _chat_jid: &Jid, + state: &AccountState, +) { + let Some(ref img) = msg.image_message else { + return; + }; + let caption = img.caption.as_deref().unwrap_or("").to_string(); + let mime = img.mimetype.as_deref().unwrap_or("image/jpeg").to_string(); + + match client.download(img.as_ref()).await { + Ok(image_data) => { + debug!(account_id, size = image_data.len(), %mime, "downloaded WhatsApp image"); + + let (final_data, media_type) = match moltis_media::image_ops::optimize_for_llm( + &image_data, + None, + ) { + Ok(optimized) => { + if optimized.was_resized { + info!( + account_id, + original_size = image_data.len(), + final_size = optimized.data.len(), + original_dims = %format!("{}x{}", optimized.original_width, optimized.original_height), + final_dims = %format!("{}x{}", optimized.final_width, optimized.final_height), + "resized image for LLM" + ); + } + (optimized.data, optimized.media_type) + }, + Err(e) => { + warn!(account_id, error = %e, "failed to optimize image, using original"); + (image_data, mime) + }, + }; + + let attachment = ChannelAttachment { + media_type, + data: final_data, + }; + if let Some(ref sink) = state.event_sink { + sink.dispatch_to_chat_with_attachments(&caption, vec![attachment], reply_to, meta) + .await; + } + }, + Err(e) => { + warn!(account_id, error = %e, "failed to download WhatsApp image"); + let fallback = if caption.is_empty() { + "[Photo - download failed]".to_string() + } else { + caption + }; + if let Some(ref sink) = state.event_sink { + sink.dispatch_to_chat(&fallback, reply_to, meta).await; + } + }, + } +} + +/// Handle an inbound voice/audio message: download, transcribe (STT), dispatch as text. +#[allow(clippy::too_many_arguments)] +async fn handle_voice_audio( + msg: &wa::Message, + client: &Client, + account_id: &str, + kind: ChannelMessageKind, + reply_to: ChannelReplyTarget, + meta: ChannelMessageMeta, + chat_jid: &Jid, + state: &AccountState, +) { + let Some(ref audio) = msg.audio_message else { + return; + }; + + // Determine format from MIME type. + let format = audio + .mimetype + .as_deref() + .map(|m| match m { + "audio/ogg" | "audio/ogg; codecs=opus" => "ogg", + "audio/mpeg" | "audio/mp3" => "mp3", + "audio/mp4" | "audio/m4a" | "audio/x-m4a" | "audio/aac" => "m4a", + "audio/wav" | "audio/x-wav" => "wav", + "audio/webm" => "webm", + _ => "ogg", // WhatsApp voice messages default to OGG Opus + }) + .unwrap_or("ogg") + .to_string(); + + let kind_label = if matches!(kind, ChannelMessageKind::Voice) { + "voice" + } else { + "audio" + }; + + // Check STT availability. + let stt_available = if let Some(ref sink) = state.event_sink { + sink.voice_stt_available().await + } else { + false + }; + + if !stt_available { + // No STT — send a guidance message. + let reply_msg = wa::Message { + conversation: Some(format!( + "I received your {kind_label} message but voice transcription is not available. Please send a text message instead." + )), + ..Default::default() + }; + let _ = state.send_message(chat_jid.clone(), reply_msg).await; + return; + } + + match client.download(audio.as_ref()).await { + Ok(audio_data) => { + debug!(account_id, size = audio_data.len(), %format, kind_label, "downloaded WhatsApp audio"); + + if let Some(ref sink) = state.event_sink { + match sink.transcribe_voice(&audio_data, &format).await { + Ok(transcribed) => { + sink.dispatch_to_chat(&transcribed, reply_to, meta).await; + }, + Err(e) => { + warn!(account_id, error = %e, "voice transcription failed"); + let fallback = format!( + "[{} message - transcription failed]", + capitalize(kind_label) + ); + sink.dispatch_to_chat(&fallback, reply_to, meta).await; + }, + } + } + }, + Err(e) => { + warn!(account_id, error = %e, "failed to download WhatsApp audio"); + let reply_msg = wa::Message { + conversation: Some(format!( + "I received your {kind_label} message but couldn't download the audio. Please try again." + )), + ..Default::default() + }; + let _ = state.send_message(chat_jid.clone(), reply_msg).await; + }, + } +} + +/// Handle an inbound video message: download and dispatch with caption. +#[allow(clippy::too_many_arguments)] +async fn handle_video( + msg: &wa::Message, + _client: &Client, + _account_id: &str, + reply_to: ChannelReplyTarget, + meta: ChannelMessageMeta, + _chat_jid: &Jid, + state: &AccountState, +) { + let Some(ref video) = msg.video_message else { + return; + }; + let caption = video.caption.as_deref().unwrap_or("").to_string(); + + // Try to extract a thumbnail if available (jpeg_thumbnail field). + // Video files can be large; for now dispatch the thumbnail as an image + // attachment so the LLM can at least see the preview. + if let Some(ref thumb) = video.jpeg_thumbnail + && !thumb.is_empty() + { + let attachment = ChannelAttachment { + media_type: "image/jpeg".to_string(), + data: thumb.clone(), + }; + let text = if caption.is_empty() { + "[Video message - thumbnail shown]".to_string() + } else { + format!("{caption}\n[Video message - thumbnail shown]") + }; + if let Some(ref sink) = state.event_sink { + sink.dispatch_to_chat_with_attachments(&text, vec![attachment], reply_to, meta) + .await; + } + return; + } + + // No thumbnail available — send caption or placeholder text. + let text = if caption.is_empty() { + "[Video message received - video playback not supported]".to_string() + } else { + format!("{caption}\n[Video message - playback not supported]") + }; + if let Some(ref sink) = state.event_sink { + sink.dispatch_to_chat(&text, reply_to, meta).await; + } +} + +/// Handle an inbound document message: dispatch with caption. +#[allow(clippy::too_many_arguments)] +async fn handle_document( + msg: &wa::Message, + _client: &Client, + account_id: &str, + reply_to: ChannelReplyTarget, + meta: ChannelMessageMeta, + _chat_jid: &Jid, + state: &AccountState, +) { + let Some(ref doc) = msg.document_message else { + return; + }; + let caption = doc.caption.as_deref().unwrap_or("").to_string(); + let filename = doc.file_name.as_deref().unwrap_or("unknown"); + let mime = doc + .mimetype + .as_deref() + .unwrap_or("application/octet-stream"); + + info!(account_id, filename, mime, "received document message"); + + let text = if caption.is_empty() { + format!("[Document received: {filename} ({mime})]") + } else { + format!("{caption}\n[Document: {filename} ({mime})]") + }; + if let Some(ref sink) = state.event_sink { + sink.dispatch_to_chat(&text, reply_to, meta).await; + } +} + +/// Handle an inbound location or live location message. +async fn handle_location( + msg: &wa::Message, + account_id: &str, + reply_to: ChannelReplyTarget, + meta: ChannelMessageMeta, + chat_jid: &Jid, + state: &AccountState, +) { + // Check for static location first, then live location. + let (lat, lon, is_live) = if let Some(ref loc) = msg.location_message { + let lat = loc.degrees_latitude.unwrap_or(0.0); + let lon = loc.degrees_longitude.unwrap_or(0.0); + let is_live = loc.is_live.unwrap_or(false); + (lat, lon, is_live) + } else if let Some(ref loc) = msg.live_location_message { + let lat = loc.degrees_latitude.unwrap_or(0.0); + let lon = loc.degrees_longitude.unwrap_or(0.0); + (lat, lon, true) + } else { + return; + }; + + // Try to resolve a pending tool-triggered location request. + let resolved = if let Some(ref sink) = state.event_sink { + sink.update_location(&reply_to, lat, lon).await + } else { + false + }; + + if resolved { + let confirmation = wa::Message { + conversation: Some("Location updated.".into()), + ..Default::default() + }; + if let Err(e) = state.send_message(chat_jid.clone(), confirmation).await { + warn!(account_id, error = %e, "failed to send location confirmation"); + } + return; + } + + if is_live { + // Live location share — acknowledge silently. Subsequent updates will + // continue to try resolving pending tool requests. + debug!(account_id, lat, lon, "received live location update"); + return; + } + + // Static location — dispatch to the LLM. + let text = format!("I'm sharing my location: {lat}, {lon}"); + if let Some(ref sink) = state.event_sink { + sink.dispatch_to_chat(&text, reply_to, meta).await; + } +} + +fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(c) => c.to_uppercase().to_string() + chars.as_str(), + } +} + +/// Classify the inbound message kind based on its content. +/// +/// Media types take priority over text — an image with a caption is still `Photo`, +/// not `Text`. This ensures the media handler runs and can include the caption +/// alongside the attachment. +fn classify_message(msg: &wa::Message, text: &str) -> ChannelMessageKind { + if msg.image_message.is_some() { + ChannelMessageKind::Photo + } else if msg.audio_message.is_some() { + if msg + .audio_message + .as_ref() + .is_some_and(|a| a.ptt.unwrap_or(false)) + { + ChannelMessageKind::Voice + } else { + ChannelMessageKind::Audio + } + } else if msg.video_message.is_some() { + ChannelMessageKind::Video + } else if msg.document_message.is_some() { + ChannelMessageKind::Document + } else if msg.location_message.is_some() || msg.live_location_message.is_some() { + ChannelMessageKind::Location + } else if !text.is_empty() { + ChannelMessageKind::Text + } else { + ChannelMessageKind::Other + } +} + +/// Handle OTP challenge/verification flow for a non-allowlisted DM user. +/// +/// Called when `dm_policy = Allowlist`, the peer is not on the allowlist, and +/// `otp_self_approval` is enabled. +#[allow(clippy::too_many_arguments)] +async fn handle_otp_flow( + accounts: &AccountStateMap, + account_id: &str, + peer_id: &str, + username: Option<&str>, + sender_name: Option<&str>, + body: &str, + chat_jid: &Jid, + state: &AccountState, +) { + let has_pending = { + let accts = accounts.read().unwrap(); + accts + .get(account_id) + .map(|s| { + let otp = s.otp.lock().unwrap(); + otp.has_pending(peer_id) + }) + .unwrap_or(false) + }; + + if has_pending { + let trimmed = body.trim(); + if trimmed.len() != 6 || !trimmed.chars().all(|c| c.is_ascii_digit()) { + // Non-code message while OTP pending — silently ignore. + return; + } + + let result = { + let accts = accounts.read().unwrap(); + match accts.get(account_id) { + Some(s) => { + let mut otp = s.otp.lock().unwrap(); + otp.verify(peer_id, trimmed) + }, + None => return, + } + }; + + match result { + OtpVerifyResult::Approved => { + let reply = wa::Message { + conversation: Some("Access granted! You can now use this bot.".into()), + ..Default::default() + }; + let _ = state.send_message(chat_jid.clone(), reply).await; + + // Emit OTP resolved event for the gateway to persist the allowlist change. + if let Some(ref sink) = state.event_sink { + sink.emit(ChannelEvent::OtpResolved { + channel_type: ChannelType::Whatsapp, + account_id: account_id.to_string(), + peer_id: peer_id.to_string(), + username: username.map(String::from), + resolution: "approved".to_string(), + }) + .await; + } + }, + OtpVerifyResult::WrongCode { attempts_left } => { + let reply = wa::Message { + conversation: Some(format!( + "Wrong code. {attempts_left} attempt{} remaining.", + if attempts_left == 1 { + "" + } else { + "s" + } + )), + ..Default::default() + }; + let _ = state.send_message(chat_jid.clone(), reply).await; + }, + OtpVerifyResult::LockedOut => { + let reply = wa::Message { + conversation: Some("Too many failed attempts. Please try again later.".into()), + ..Default::default() + }; + let _ = state.send_message(chat_jid.clone(), reply).await; + }, + OtpVerifyResult::Expired => { + let reply = wa::Message { + conversation: Some( + "Your code has expired. Please send any message to get a new code.".into(), + ), + ..Default::default() + }; + let _ = state.send_message(chat_jid.clone(), reply).await; + }, + OtpVerifyResult::NoPending => {}, + } + return; + } + + // No pending challenge — initiate one. + let init_result = { + let accts = accounts.read().unwrap(); + match accts.get(account_id) { + Some(s) => { + let mut otp = s.otp.lock().unwrap(); + otp.initiate( + peer_id, + username.map(String::from), + sender_name.map(String::from), + ) + }, + None => return, + } + }; + + match init_result { + OtpInitResult::Created(code) => { + info!(account_id, peer_id, code, "OTP challenge issued"); + let reply = wa::Message { + conversation: Some(OTP_CHALLENGE_MSG.to_string()), + ..Default::default() + }; + let _ = state.send_message(chat_jid.clone(), reply).await; + + // Compute expires_at as epoch seconds (5 minutes from now). + let expires_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 + + 300; + + if let Some(ref sink) = state.event_sink { + sink.emit(ChannelEvent::OtpChallenge { + channel_type: ChannelType::Whatsapp, + account_id: account_id.to_string(), + peer_id: peer_id.to_string(), + username: username.map(String::from), + sender_name: sender_name.map(String::from), + code, + expires_at, + }) + .await; + } + }, + OtpInitResult::AlreadyPending => { + // Resend the challenge message. + let reply = wa::Message { + conversation: Some(OTP_CHALLENGE_MSG.to_string()), + ..Default::default() + }; + let _ = state.send_message(chat_jid.clone(), reply).await; + }, + OtpInitResult::LockedOut => { + let reply = wa::Message { + conversation: Some("Too many failed attempts. Please try again later.".into()), + ..Default::default() + }; + let _ = state.send_message(chat_jid.clone(), reply).await; + }, + } +} diff --git a/crates/whatsapp/src/lib.rs b/crates/whatsapp/src/lib.rs new file mode 100644 index 00000000..6dbb75a0 --- /dev/null +++ b/crates/whatsapp/src/lib.rs @@ -0,0 +1,17 @@ +//! WhatsApp channel plugin for moltis. +//! +//! Implements `ChannelPlugin` using the `whatsapp-rust` library to receive and +//! send messages via WhatsApp Linked Devices (QR code pairing). + +pub mod access; +pub mod config; +pub mod connection; +pub mod handlers; +pub mod memory_store; +pub mod otp; +pub mod outbound; +pub mod plugin; +pub mod sled_store; +pub mod state; + +pub use {config::WhatsAppAccountConfig, plugin::WhatsAppPlugin}; diff --git a/crates/whatsapp/src/memory_store.rs b/crates/whatsapp/src/memory_store.rs new file mode 100644 index 00000000..93876189 --- /dev/null +++ b/crates/whatsapp/src/memory_store.rs @@ -0,0 +1,570 @@ +//! In-memory storage backend for whatsapp-rust. +//! +//! This is a temporary solution while `whatsapp-rust-sqlite-storage` has a +//! `libsqlite3-sys` version conflict with `sqlx`. Session state does NOT +//! persist across restarts — the user must re-scan the QR code. +//! +//! TODO: Replace with `whatsapp-rust-sqlite-storage` once sqlx 0.9 stabilises +//! (it uses a range-based libsqlite3-sys dep that resolves the conflict). + +use std::{fmt::Write, sync::Arc}; + +use { + async_trait::async_trait, + dashmap::DashMap, + wacore::{ + appstate::{hash::HashState, processor::AppStateMutationMAC}, + store::{error::Result, traits::*}, + }, +}; + +/// Hex-encode bytes without pulling in the `hex` crate. +fn hex_encode(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + let _ = write!(s, "{b:02x}"); + } + s +} + +/// In-memory store implementing all wacore storage traits. +#[derive(Clone, Default)] +pub struct MemoryStore { + identities: Arc>>, + sessions: Arc>>, + prekeys: Arc, bool)>>, + signed_prekeys: Arc>>, + sender_keys: Arc>>, + sync_keys: Arc, AppStateSyncKey>>, + app_state_versions: Arc>, + /// Keyed by `"{name}:{version}:{hex(index_mac)}"`. + mutation_macs: Arc>>, + /// Keyed by `"{name}:{version}"` → list of index_macs stored at that version. + mutation_mac_indexes: Arc>>>, + device_data: Arc>>, + device_id: Arc, + skdm_recipients: Arc>>, + lid_mappings: Arc>, + /// Phone number → LID reverse index. + pn_mappings: Arc>, + device_list_records: Arc>, + /// Keyed by `"{group_jid}:{participant}"`. + sender_key_forget_marks: Arc>, + /// Base keys keyed by `"{address}:{message_id}"`. + base_keys: Arc>>, +} + +impl MemoryStore { + pub fn new() -> Self { + Self::default() + } +} + +// ============================================================================ +// SignalStore +// ============================================================================ + +#[async_trait] +impl SignalStore for MemoryStore { + async fn put_identity(&self, address: &str, key: [u8; 32]) -> Result<()> { + self.identities.insert(address.to_string(), key.to_vec()); + Ok(()) + } + + async fn load_identity(&self, address: &str) -> Result>> { + Ok(self.identities.get(address).map(|v| v.value().clone())) + } + + async fn delete_identity(&self, address: &str) -> Result<()> { + self.identities.remove(address); + Ok(()) + } + + async fn get_session(&self, address: &str) -> Result>> { + Ok(self.sessions.get(address).map(|v| v.value().clone())) + } + + async fn put_session(&self, address: &str, session: &[u8]) -> Result<()> { + self.sessions.insert(address.to_string(), session.to_vec()); + Ok(()) + } + + async fn delete_session(&self, address: &str) -> Result<()> { + self.sessions.remove(address); + Ok(()) + } + + async fn store_prekey(&self, id: u32, record: &[u8], uploaded: bool) -> Result<()> { + self.prekeys.insert(id, (record.to_vec(), uploaded)); + Ok(()) + } + + async fn load_prekey(&self, id: u32) -> Result>> { + Ok(self.prekeys.get(&id).map(|v| v.value().0.clone())) + } + + async fn remove_prekey(&self, id: u32) -> Result<()> { + self.prekeys.remove(&id); + Ok(()) + } + + async fn store_signed_prekey(&self, id: u32, record: &[u8]) -> Result<()> { + self.signed_prekeys.insert(id, record.to_vec()); + Ok(()) + } + + async fn load_signed_prekey(&self, id: u32) -> Result>> { + Ok(self.signed_prekeys.get(&id).map(|v| v.value().clone())) + } + + async fn load_all_signed_prekeys(&self) -> Result)>> { + Ok(self + .signed_prekeys + .iter() + .map(|e| (*e.key(), e.value().clone())) + .collect()) + } + + async fn remove_signed_prekey(&self, id: u32) -> Result<()> { + self.signed_prekeys.remove(&id); + Ok(()) + } + + async fn put_sender_key(&self, address: &str, record: &[u8]) -> Result<()> { + self.sender_keys + .insert(address.to_string(), record.to_vec()); + Ok(()) + } + + async fn get_sender_key(&self, address: &str) -> Result>> { + Ok(self.sender_keys.get(address).map(|v| v.value().clone())) + } + + async fn delete_sender_key(&self, address: &str) -> Result<()> { + self.sender_keys.remove(address); + Ok(()) + } +} + +// ============================================================================ +// AppSyncStore +// ============================================================================ + +#[async_trait] +impl AppSyncStore for MemoryStore { + async fn get_sync_key(&self, key_id: &[u8]) -> Result> { + Ok(self.sync_keys.get(key_id).map(|v| v.value().clone())) + } + + async fn set_sync_key(&self, key_id: &[u8], key: AppStateSyncKey) -> Result<()> { + self.sync_keys.insert(key_id.to_vec(), key); + Ok(()) + } + + async fn get_version(&self, name: &str) -> Result { + Ok(self + .app_state_versions + .get(name) + .map(|v| v.value().clone()) + .unwrap_or_default()) + } + + async fn set_version(&self, name: &str, state: HashState) -> Result<()> { + self.app_state_versions.insert(name.to_string(), state); + Ok(()) + } + + async fn put_mutation_macs( + &self, + name: &str, + version: u64, + mutations: &[AppStateMutationMAC], + ) -> Result<()> { + let version_key = format!("{name}:{version}"); + let mut indexes = Vec::new(); + for mac in mutations { + let mac_key = format!("{name}:{version}:{}", hex_encode(&mac.index_mac)); + self.mutation_macs.insert(mac_key, mac.value_mac.clone()); + indexes.push(mac.index_mac.clone()); + } + self.mutation_mac_indexes.insert(version_key, indexes); + Ok(()) + } + + async fn get_mutation_mac(&self, name: &str, index_mac: &[u8]) -> Result>> { + // Search across all versions for this name + index_mac combo. + for entry in self.mutation_mac_indexes.iter() { + if entry.key().starts_with(&format!("{name}:")) { + let version_key = entry.key(); + let mac_key = format!("{version_key}:{}", hex_encode(index_mac)); + if let Some(value_mac) = self.mutation_macs.get(&mac_key) { + return Ok(Some(value_mac.value().clone())); + } + } + } + Ok(None) + } + + async fn delete_mutation_macs(&self, name: &str, index_macs: &[Vec]) -> Result<()> { + for index_mac in index_macs { + let hex_mac = hex_encode(index_mac); + // Remove from all versions. + let keys_to_remove: Vec = self + .mutation_macs + .iter() + .filter(|e| e.key().starts_with(&format!("{name}:")) && e.key().ends_with(&hex_mac)) + .map(|e| e.key().clone()) + .collect(); + for key in keys_to_remove { + self.mutation_macs.remove(&key); + } + } + Ok(()) + } +} + +// ============================================================================ +// ProtocolStore +// ============================================================================ + +#[async_trait] +impl ProtocolStore for MemoryStore { + async fn get_skdm_recipients(&self, group_jid: &str) -> Result> { + Ok(self + .skdm_recipients + .get(group_jid) + .map(|v| v.value().clone()) + .unwrap_or_default()) + } + + async fn add_skdm_recipients(&self, group_jid: &str, device_jids: &[String]) -> Result<()> { + self.skdm_recipients + .entry(group_jid.to_string()) + .or_default() + .extend(device_jids.iter().cloned()); + Ok(()) + } + + async fn clear_skdm_recipients(&self, group_jid: &str) -> Result<()> { + self.skdm_recipients.remove(group_jid); + Ok(()) + } + + async fn get_lid_mapping(&self, lid: &str) -> Result> { + Ok(self.lid_mappings.get(lid).map(|v| v.value().clone())) + } + + async fn get_pn_mapping(&self, phone: &str) -> Result> { + if let Some(lid) = self.pn_mappings.get(phone) { + return Ok(self + .lid_mappings + .get(lid.value()) + .map(|v| v.value().clone())); + } + Ok(None) + } + + async fn put_lid_mapping(&self, entry: &LidPnMappingEntry) -> Result<()> { + self.pn_mappings + .insert(entry.phone_number.clone(), entry.lid.clone()); + self.lid_mappings.insert(entry.lid.clone(), entry.clone()); + Ok(()) + } + + async fn get_all_lid_mappings(&self) -> Result> { + Ok(self + .lid_mappings + .iter() + .map(|e| e.value().clone()) + .collect()) + } + + async fn save_base_key(&self, address: &str, message_id: &str, base_key: &[u8]) -> Result<()> { + let key = format!("{address}:{message_id}"); + self.base_keys.insert(key, base_key.to_vec()); + Ok(()) + } + + async fn has_same_base_key( + &self, + address: &str, + message_id: &str, + current_base_key: &[u8], + ) -> Result { + let key = format!("{address}:{message_id}"); + Ok(self + .base_keys + .get(&key) + .is_some_and(|v| v.value() == current_base_key)) + } + + async fn delete_base_key(&self, address: &str, message_id: &str) -> Result<()> { + let key = format!("{address}:{message_id}"); + self.base_keys.remove(&key); + Ok(()) + } + + async fn update_device_list(&self, record: DeviceListRecord) -> Result<()> { + self.device_list_records.insert(record.user.clone(), record); + Ok(()) + } + + async fn get_devices(&self, user: &str) -> Result> { + Ok(self + .device_list_records + .get(user) + .map(|v| v.value().clone())) + } + + async fn mark_forget_sender_key(&self, group_jid: &str, participant: &str) -> Result<()> { + let key = format!("{group_jid}:{participant}"); + self.sender_key_forget_marks.insert(key, true); + Ok(()) + } + + async fn consume_forget_marks(&self, group_jid: &str) -> Result> { + let prefix = format!("{group_jid}:"); + let keys: Vec = self + .sender_key_forget_marks + .iter() + .filter(|e| e.key().starts_with(&prefix)) + .map(|e| e.key().clone()) + .collect(); + + let mut participants = Vec::new(); + for key in keys { + self.sender_key_forget_marks.remove(&key); + if let Some(participant) = key.strip_prefix(&prefix) { + participants.push(participant.to_string()); + } + } + Ok(participants) + } +} + +// ============================================================================ +// DeviceStore +// ============================================================================ + +#[async_trait] +impl DeviceStore for MemoryStore { + async fn save(&self, device: &wacore::store::Device) -> Result<()> { + let mut data = self.device_data.write().await; + *data = Some(device.clone()); + Ok(()) + } + + async fn load(&self) -> Result> { + let data = self.device_data.read().await; + Ok(data.clone()) + } + + async fn exists(&self) -> Result { + let data = self.device_data.read().await; + Ok(data.is_some()) + } + + async fn create(&self) -> Result { + let id = self + .device_id + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn identity_roundtrip() { + let store = MemoryStore::new(); + let key = [42u8; 32]; + store + .put_identity("test@s.whatsapp.net", key) + .await + .unwrap(); + let loaded = store.load_identity("test@s.whatsapp.net").await.unwrap(); + assert_eq!(loaded, Some(key.to_vec())); + } + + #[tokio::test] + async fn session_roundtrip() { + let store = MemoryStore::new(); + let data = b"session-data"; + store.put_session("addr", data).await.unwrap(); + let loaded = store.get_session("addr").await.unwrap(); + assert_eq!(loaded, Some(data.to_vec())); + assert!(store.has_session("addr").await.unwrap()); + assert!(!store.has_session("missing").await.unwrap()); + } + + #[tokio::test] + async fn device_store_roundtrip() { + let store = MemoryStore::new(); + assert!(!store.exists().await.unwrap()); + let id = store.create().await.unwrap(); + assert_eq!(id, 0); + } + + #[tokio::test] + async fn prekey_operations() { + let store = MemoryStore::new(); + store.store_prekey(1, b"pk1", false).await.unwrap(); + store.store_prekey(2, b"pk2", true).await.unwrap(); + assert_eq!(store.load_prekey(1).await.unwrap(), Some(b"pk1".to_vec())); + store.remove_prekey(1).await.unwrap(); + assert!(store.load_prekey(1).await.unwrap().is_none()); + } + + #[tokio::test] + async fn signed_prekey_operations() { + let store = MemoryStore::new(); + store.store_signed_prekey(10, b"spk10").await.unwrap(); + store.store_signed_prekey(20, b"spk20").await.unwrap(); + let all = store.load_all_signed_prekeys().await.unwrap(); + assert_eq!(all.len(), 2); + store.remove_signed_prekey(10).await.unwrap(); + let all = store.load_all_signed_prekeys().await.unwrap(); + assert_eq!(all.len(), 1); + } + + #[tokio::test] + async fn sender_key_roundtrip() { + let store = MemoryStore::new(); + store.put_sender_key("addr1", b"key1").await.unwrap(); + assert_eq!( + store.get_sender_key("addr1").await.unwrap(), + Some(b"key1".to_vec()) + ); + store.delete_sender_key("addr1").await.unwrap(); + assert!(store.get_sender_key("addr1").await.unwrap().is_none()); + } + + #[tokio::test] + async fn sync_key_roundtrip() { + let store = MemoryStore::new(); + let key = AppStateSyncKey { + key_data: vec![1, 2, 3], + fingerprint: vec![4, 5], + timestamp: 12345, + }; + store.set_sync_key(b"test-key", key.clone()).await.unwrap(); + let loaded = store.get_sync_key(b"test-key").await.unwrap(); + assert!(loaded.is_some()); + assert_eq!(loaded.unwrap().timestamp, 12345); + } + + #[tokio::test] + async fn version_roundtrip() { + let store = MemoryStore::new(); + let state = store.get_version("contacts").await.unwrap(); + assert_eq!(state.version, 0); // default + + let new_state = HashState { + version: 5, + ..Default::default() + }; + store.set_version("contacts", new_state).await.unwrap(); + let loaded = store.get_version("contacts").await.unwrap(); + assert_eq!(loaded.version, 5); + } + + #[tokio::test] + async fn skdm_recipients() { + let store = MemoryStore::new(); + let recips = store.get_skdm_recipients("group1").await.unwrap(); + assert!(recips.is_empty()); + + store + .add_skdm_recipients("group1", &["dev1".into(), "dev2".into()]) + .await + .unwrap(); + let recips = store.get_skdm_recipients("group1").await.unwrap(); + assert_eq!(recips.len(), 2); + + store.clear_skdm_recipients("group1").await.unwrap(); + assert!( + store + .get_skdm_recipients("group1") + .await + .unwrap() + .is_empty() + ); + } + + #[tokio::test] + async fn lid_mapping() { + let store = MemoryStore::new(); + let entry = LidPnMappingEntry { + lid: "100000012345678".into(), + phone_number: "559980000001".into(), + created_at: 1000, + updated_at: 2000, + learning_source: "usync".into(), + }; + store.put_lid_mapping(&entry).await.unwrap(); + + let by_lid = store.get_lid_mapping("100000012345678").await.unwrap(); + assert!(by_lid.is_some()); + assert_eq!(by_lid.unwrap().phone_number, "559980000001"); + + let by_pn = store.get_pn_mapping("559980000001").await.unwrap(); + assert!(by_pn.is_some()); + + let all = store.get_all_lid_mappings().await.unwrap(); + assert_eq!(all.len(), 1); + } + + #[tokio::test] + async fn base_key_operations() { + let store = MemoryStore::new(); + let key = b"base-key-data"; + store.save_base_key("addr", "msg1", key).await.unwrap(); + assert!(store.has_same_base_key("addr", "msg1", key).await.unwrap()); + assert!( + !store + .has_same_base_key("addr", "msg1", b"other") + .await + .unwrap() + ); + store.delete_base_key("addr", "msg1").await.unwrap(); + assert!(!store.has_same_base_key("addr", "msg1", key).await.unwrap()); + } + + #[tokio::test] + async fn device_list() { + let store = MemoryStore::new(); + let record = DeviceListRecord { + user: "user1".into(), + devices: vec![DeviceInfo { + device_id: 0, + key_index: Some(1), + }], + timestamp: 1000, + phash: None, + }; + store.update_device_list(record).await.unwrap(); + let loaded = store.get_devices("user1").await.unwrap(); + assert!(loaded.is_some()); + assert_eq!(loaded.unwrap().devices.len(), 1); + } + + #[tokio::test] + async fn forget_marks() { + let store = MemoryStore::new(); + store + .mark_forget_sender_key("group1", "user_a") + .await + .unwrap(); + store + .mark_forget_sender_key("group1", "user_b") + .await + .unwrap(); + let marks = store.consume_forget_marks("group1").await.unwrap(); + assert_eq!(marks.len(), 2); + // Consumed — should be empty now. + let marks = store.consume_forget_marks("group1").await.unwrap(); + assert!(marks.is_empty()); + } +} diff --git a/crates/whatsapp/src/otp.rs b/crates/whatsapp/src/otp.rs new file mode 100644 index 00000000..f53dca2f --- /dev/null +++ b/crates/whatsapp/src/otp.rs @@ -0,0 +1,371 @@ +//! In-memory OTP state for self-approval of non-allowlisted DM users. +//! +//! When `dm_policy = Allowlist` and `otp_self_approval = true`, the bot issues +//! a 6-digit OTP challenge to unknown users. If they reply with the correct +//! code they are automatically added to the allowlist. + +use std::{ + collections::HashMap, + time::{Duration, Instant, SystemTime}, +}; + +use rand::Rng; + +/// How long an OTP code stays valid. +const OTP_TTL: Duration = Duration::from_secs(300); + +/// Maximum wrong-code attempts before lockout. +const MAX_ATTEMPTS: u32 = 3; + +/// Per-account OTP state. +pub struct OtpState { + challenges: HashMap, + lockouts: HashMap, + cooldown: Duration, +} + +/// A pending OTP challenge for a single peer. +pub struct OtpChallenge { + pub code: String, + pub peer_id: String, + pub username: Option, + pub sender_name: Option, + pub created_at: Instant, + pub expires_at: Instant, + pub attempts: u32, +} + +/// Lockout state after too many failed attempts. +struct Lockout { + until: Instant, +} + +/// Result of initiating a challenge. +#[derive(Debug, PartialEq, Eq)] +pub enum OtpInitResult { + /// Challenge created; contains the 6-digit code. + Created(String), + /// A challenge already exists for this peer. + AlreadyPending, + /// Peer is locked out. + LockedOut, +} + +/// Result of verifying a code. +#[derive(Debug, PartialEq, Eq)] +pub enum OtpVerifyResult { + /// Code matched — peer should be approved. + Approved, + /// Wrong code; `attempts_left` remaining before lockout. + WrongCode { attempts_left: u32 }, + /// Peer is locked out after too many failures. + LockedOut, + /// No pending challenge for this peer. + NoPending, + /// The challenge has expired. + Expired, +} + +/// Snapshot of a pending challenge for external consumers (API/UI). +#[derive(Debug, Clone, serde::Serialize)] +pub struct OtpChallengeInfo { + pub peer_id: String, + pub username: Option, + pub sender_name: Option, + pub code: String, + pub expires_at: i64, +} + +impl OtpState { + pub fn new(cooldown_secs: u64) -> Self { + Self { + challenges: HashMap::new(), + lockouts: HashMap::new(), + cooldown: Duration::from_secs(cooldown_secs), + } + } + + /// Initiate an OTP challenge for `peer_id`. + pub fn initiate( + &mut self, + peer_id: &str, + username: Option, + sender_name: Option, + ) -> OtpInitResult { + let now = Instant::now(); + + // Check lockout first. + if let Some(lockout) = self.lockouts.get(peer_id) { + if now < lockout.until { + return OtpInitResult::LockedOut; + } + self.lockouts.remove(peer_id); + } + + // Check for existing unexpired challenge. + if let Some(existing) = self.challenges.get(peer_id) { + if now < existing.expires_at { + return OtpInitResult::AlreadyPending; + } + self.challenges.remove(peer_id); + } + + let code = generate_otp_code(); + let challenge = OtpChallenge { + code: code.clone(), + peer_id: peer_id.to_string(), + username, + sender_name, + created_at: now, + expires_at: now + OTP_TTL, + attempts: 0, + }; + self.challenges.insert(peer_id.to_string(), challenge); + OtpInitResult::Created(code) + } + + /// Verify a code submitted by `peer_id`. + pub fn verify(&mut self, peer_id: &str, code: &str) -> OtpVerifyResult { + let now = Instant::now(); + + // Check lockout. + if let Some(lockout) = self.lockouts.get(peer_id) { + if now < lockout.until { + return OtpVerifyResult::LockedOut; + } + self.lockouts.remove(peer_id); + } + + let challenge = match self.challenges.get_mut(peer_id) { + Some(c) => c, + None => return OtpVerifyResult::NoPending, + }; + + // Check expiry. + if now >= challenge.expires_at { + self.challenges.remove(peer_id); + return OtpVerifyResult::Expired; + } + + if challenge.code == code { + self.challenges.remove(peer_id); + return OtpVerifyResult::Approved; + } + + // Wrong code. + challenge.attempts += 1; + if challenge.attempts >= MAX_ATTEMPTS { + self.challenges.remove(peer_id); + self.lockouts.insert(peer_id.to_string(), Lockout { + until: now + self.cooldown, + }); + return OtpVerifyResult::LockedOut; + } + + OtpVerifyResult::WrongCode { + attempts_left: MAX_ATTEMPTS - challenge.attempts, + } + } + + /// Check if a challenge is pending (and not expired) for `peer_id`. + pub fn has_pending(&self, peer_id: &str) -> bool { + self.challenges + .get(peer_id) + .is_some_and(|c| Instant::now() < c.expires_at) + } + + /// Check if `peer_id` is currently locked out. + pub fn is_locked_out(&self, peer_id: &str) -> bool { + self.lockouts + .get(peer_id) + .is_some_and(|l| Instant::now() < l.until) + } + + /// List all pending (non-expired) challenges with epoch timestamps. + pub fn list_pending(&self) -> Vec { + let now_instant = Instant::now(); + let now_system = SystemTime::now(); + + self.challenges + .values() + .filter(|c| now_instant < c.expires_at) + .map(|c| { + let remaining = c.expires_at.saturating_duration_since(now_instant); + let expires_epoch = now_system + .checked_add(remaining) + .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + + OtpChallengeInfo { + peer_id: c.peer_id.clone(), + username: c.username.clone(), + sender_name: c.sender_name.clone(), + code: c.code.clone(), + expires_at: expires_epoch, + } + }) + .collect() + } + + /// Remove expired challenges and elapsed lockouts. + pub fn evict_expired(&mut self) { + let now = Instant::now(); + self.challenges.retain(|_, c| now < c.expires_at); + self.lockouts.retain(|_, l| now < l.until); + } +} + +/// Generate a random 6-digit OTP code. +fn generate_otp_code() -> String { + let code: u32 = rand::rng().random_range(100_000..1_000_000); + code.to_string() +} + +/// Message sent to the WhatsApp user when an OTP challenge is created. +/// The code is NOT included — it is only visible to the admin in the web UI. +pub const OTP_CHALLENGE_MSG: &str = "To use this bot, please enter the verification code.\n\nAsk the bot owner for the code \u{2014} it is visible in the web UI under Channels \u{2192} Senders.\n\nThe code expires in 5 minutes."; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn initiate_creates_challenge() { + let mut state = OtpState::new(300); + match state.initiate("user1", Some("alice".into()), Some("Alice".into())) { + OtpInitResult::Created(code) => { + assert_eq!(code.len(), 6); + assert!(code.chars().all(|c| c.is_ascii_digit())); + }, + other => panic!("expected Created, got {other:?}"), + } + assert!(state.has_pending("user1")); + } + + #[test] + fn initiate_already_pending() { + let mut state = OtpState::new(300); + assert!(matches!( + state.initiate("user1", None, None), + OtpInitResult::Created(_) + )); + assert_eq!( + state.initiate("user1", None, None), + OtpInitResult::AlreadyPending + ); + } + + #[test] + fn verify_correct_code() { + let mut state = OtpState::new(300); + let code = match state.initiate("user1", None, None) { + OtpInitResult::Created(c) => c, + _ => unreachable!(), + }; + assert_eq!(state.verify("user1", &code), OtpVerifyResult::Approved); + assert!(!state.has_pending("user1")); + } + + #[test] + fn verify_wrong_code_lockout() { + let mut state = OtpState::new(300); + let _code = match state.initiate("user1", None, None) { + OtpInitResult::Created(c) => c, + _ => unreachable!(), + }; + assert_eq!( + state.verify("user1", "000000"), + OtpVerifyResult::WrongCode { attempts_left: 2 } + ); + assert_eq!( + state.verify("user1", "000001"), + OtpVerifyResult::WrongCode { attempts_left: 1 } + ); + assert_eq!(state.verify("user1", "000002"), OtpVerifyResult::LockedOut); + assert!(!state.has_pending("user1")); + assert!(state.is_locked_out("user1")); + } + + #[test] + fn verify_no_pending() { + let mut state = OtpState::new(300); + assert_eq!(state.verify("ghost", "123456"), OtpVerifyResult::NoPending); + } + + #[test] + fn verify_expired() { + let mut state = OtpState::new(300); + let code = match state.initiate("user1", None, None) { + OtpInitResult::Created(c) => c, + _ => unreachable!(), + }; + state.challenges.get_mut("user1").unwrap().expires_at = + Instant::now() - Duration::from_secs(1); + assert_eq!(state.verify("user1", &code), OtpVerifyResult::Expired); + } + + #[test] + fn lockout_prevents_initiate() { + let mut state = OtpState::new(300); + let _code = match state.initiate("user1", None, None) { + OtpInitResult::Created(c) => c, + _ => unreachable!(), + }; + state.verify("user1", "000000"); + state.verify("user1", "000001"); + state.verify("user1", "000002"); + assert_eq!( + state.initiate("user1", None, None), + OtpInitResult::LockedOut + ); + } + + #[test] + fn list_pending_returns_active_challenges() { + let mut state = OtpState::new(300); + state.initiate("user1", Some("alice".into()), Some("Alice".into())); + state.initiate("user2", None, None); + + let pending = state.list_pending(); + assert_eq!(pending.len(), 2); + assert!(pending.iter().any(|c| c.peer_id == "user1")); + assert!(pending.iter().any(|c| c.peer_id == "user2")); + } + + #[test] + fn evict_expired_clears_old_entries() { + let mut state = OtpState::new(300); + state.initiate("user1", None, None); + state.initiate("user2", None, None); + state.challenges.get_mut("user1").unwrap().expires_at = + Instant::now() - Duration::from_secs(1); + state.evict_expired(); + assert!(!state.has_pending("user1")); + assert!(state.has_pending("user2")); + } + + #[test] + fn otp_code_is_six_digits() { + for _ in 0..100 { + let code = generate_otp_code(); + assert_eq!(code.len(), 6); + let n: u32 = code.parse().unwrap(); + assert!(n >= 100_000); + assert!(n < 1_000_000); + } + } + + /// Security: the OTP challenge message must NEVER contain the code. + #[test] + fn security_otp_challenge_message_does_not_contain_code() { + let has_six_digits = OTP_CHALLENGE_MSG + .as_bytes() + .windows(6) + .any(|w| w.iter().all(|b| b.is_ascii_digit())); + assert!( + !has_six_digits, + "OTP challenge message must not contain 6-digit code" + ); + } +} diff --git a/crates/whatsapp/src/outbound.rs b/crates/whatsapp/src/outbound.rs new file mode 100644 index 00000000..b9e2abf5 --- /dev/null +++ b/crates/whatsapp/src/outbound.rs @@ -0,0 +1,93 @@ +use {anyhow::Result, async_trait::async_trait, tracing::debug}; + +use {wacore_binary::jid::Jid, waproto::whatsapp as wa, whatsapp_rust::ChatStateType}; + +use {moltis_channels::plugin::ChannelOutbound, moltis_common::types::ReplyPayload}; + +use crate::state::{AccountStateMap, BOT_WATERMARK}; + +/// Outbound message sender for WhatsApp. +pub struct WhatsAppOutbound { + pub(crate) accounts: AccountStateMap, +} + +impl WhatsAppOutbound { + fn get_client( + &self, + account_id: &str, + ) -> Result> { + let accounts = self.accounts.read().unwrap(); + accounts + .get(account_id) + .map(|s| std::sync::Arc::clone(&s.client)) + .ok_or_else(|| anyhow::anyhow!("unknown WhatsApp account: {account_id}")) + } + + /// Record a sent message ID for self-chat loop detection. + fn record_sent_id(&self, account_id: &str, msg_id: &str) { + let accounts = self.accounts.read().unwrap(); + if let Some(state) = accounts.get(account_id) { + state.record_sent_id(msg_id); + } + } +} + +#[async_trait] +impl ChannelOutbound for WhatsAppOutbound { + async fn send_text( + &self, + account_id: &str, + to: &str, + text: &str, + _reply_to: Option<&str>, + ) -> Result<()> { + let client = self.get_client(account_id)?; + let jid: Jid = to + .parse() + .map_err(|e| anyhow::anyhow!("invalid JID: {e:?}"))?; + + debug!( + account_id, + to, + text_len = text.len(), + "sending WhatsApp text" + ); + + let mut watermarked = text.to_string(); + watermarked.push_str(BOT_WATERMARK); + let msg = wa::Message { + conversation: Some(watermarked), + ..Default::default() + }; + let msg_id = client.send_message(jid, msg).await?; + self.record_sent_id(account_id, &msg_id); + Ok(()) + } + + async fn send_media( + &self, + account_id: &str, + to: &str, + payload: &ReplyPayload, + _reply_to: Option<&str>, + ) -> Result<()> { + // For now, send text only. Media upload support to be added. + if !payload.text.is_empty() { + self.send_text(account_id, to, &payload.text, None).await?; + } + Ok(()) + } + + async fn send_typing(&self, account_id: &str, to: &str) -> Result<()> { + let client = self.get_client(account_id)?; + let jid: Jid = to + .parse() + .map_err(|e| anyhow::anyhow!("invalid JID: {e:?}"))?; + client + .chatstate() + .send(&jid, ChatStateType::Composing) + .await + .map_err(|e| anyhow::anyhow!("chatstate error: {e}"))?; + Ok(()) + } +} diff --git a/crates/whatsapp/src/plugin.rs b/crates/whatsapp/src/plugin.rs new file mode 100644 index 00000000..7cb05715 --- /dev/null +++ b/crates/whatsapp/src/plugin.rs @@ -0,0 +1,261 @@ +use std::{ + collections::HashMap, + path::PathBuf, + sync::{Arc, RwLock}, + time::Instant, +}; + +use { + anyhow::Result, + async_trait::async_trait, + tracing::{info, warn}, +}; + +use moltis_channels::{ + ChannelEventSink, + message_log::MessageLog, + plugin::{ChannelHealthSnapshot, ChannelOutbound, ChannelPlugin, ChannelStatus}, +}; + +use crate::{ + config::WhatsAppAccountConfig, connection, outbound::WhatsAppOutbound, state::AccountStateMap, +}; + +/// Cache TTL for probe results (30 seconds). +const PROBE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(30); + +/// WhatsApp channel plugin. +pub struct WhatsAppPlugin { + accounts: AccountStateMap, + outbound: WhatsAppOutbound, + message_log: Option>, + event_sink: Option>, + data_dir: PathBuf, + probe_cache: RwLock>, +} + +impl WhatsAppPlugin { + pub fn new(data_dir: PathBuf) -> Self { + let accounts: AccountStateMap = Arc::new(RwLock::new(HashMap::new())); + let outbound = WhatsAppOutbound { + accounts: Arc::clone(&accounts), + }; + Self { + accounts, + outbound, + message_log: None, + event_sink: None, + data_dir, + probe_cache: RwLock::new(HashMap::new()), + } + } + + pub fn with_message_log(mut self, log: Arc) -> Self { + self.message_log = Some(log); + self + } + + pub fn with_event_sink(mut self, sink: Arc) -> Self { + self.event_sink = Some(sink); + self + } + + /// Get a shared reference to the outbound sender. + pub fn shared_outbound(&self) -> Arc { + Arc::new(WhatsAppOutbound { + accounts: Arc::clone(&self.accounts), + }) + } + + /// List all active account IDs. + pub fn account_ids(&self) -> Vec { + let accounts = self.accounts.read().unwrap(); + accounts.keys().cloned().collect() + } + + /// Get the config for a specific account (serialized to JSON). + pub fn account_config(&self, account_id: &str) -> Option { + let accounts = self.accounts.read().unwrap(); + accounts + .get(account_id) + .and_then(|s| serde_json::to_value(&s.config).ok()) + } + + /// Get the latest QR code data for a specific account. + pub fn latest_qr(&self, account_id: &str) -> Option { + let accounts = self.accounts.read().unwrap(); + accounts + .get(account_id) + .and_then(|s| s.latest_qr.read().ok()?.clone()) + } + + /// Update the in-memory config for an account without restarting. + /// Use for allowlist changes that don't need re-pairing. + pub fn update_account_config( + &self, + account_id: &str, + config: serde_json::Value, + ) -> anyhow::Result<()> { + let wa_config: WhatsAppAccountConfig = serde_json::from_value(config)?; + let mut accounts = self.accounts.write().unwrap(); + if let Some(state) = accounts.get_mut(account_id) { + state.config = wa_config; + Ok(()) + } else { + Err(anyhow::anyhow!("account not found: {account_id}")) + } + } + + /// List pending OTP challenges for a specific account. + pub fn pending_otp_challenges(&self, account_id: &str) -> Vec { + let accounts = self.accounts.read().unwrap(); + accounts + .get(account_id) + .map(|s| { + let otp = s.otp.lock().unwrap(); + otp.list_pending() + }) + .unwrap_or_default() + } +} + +#[async_trait] +impl ChannelPlugin for WhatsAppPlugin { + fn id(&self) -> &str { + "whatsapp" + } + + fn name(&self) -> &str { + "WhatsApp" + } + + async fn start_account(&mut self, account_id: &str, config: serde_json::Value) -> Result<()> { + let wa_config: WhatsAppAccountConfig = serde_json::from_value(config)?; + + info!(account_id, "starting WhatsApp account"); + + connection::start_connection( + account_id.to_string(), + wa_config, + Arc::clone(&self.accounts), + self.data_dir.clone(), + self.message_log.clone(), + self.event_sink.clone(), + ) + .await?; + + Ok(()) + } + + async fn stop_account(&mut self, account_id: &str) -> Result<()> { + let cancel = { + let accounts = self.accounts.read().unwrap(); + accounts.get(account_id).map(|s| s.cancel.clone()) + }; + + if let Some(cancel) = cancel { + info!(account_id, "stopping WhatsApp account"); + cancel.cancel(); + let mut accounts = self.accounts.write().unwrap(); + accounts.remove(account_id); + } else { + warn!(account_id, "WhatsApp account not found"); + } + + Ok(()) + } + + fn outbound(&self) -> Option<&dyn ChannelOutbound> { + Some(&self.outbound) + } + + fn status(&self) -> Option<&dyn ChannelStatus> { + Some(self) + } +} + +#[async_trait] +impl ChannelStatus for WhatsAppPlugin { + async fn probe(&self, account_id: &str) -> Result { + // Return cached result if fresh enough. + if let Ok(cache) = self.probe_cache.read() + && let Some((snap, ts)) = cache.get(account_id) + && ts.elapsed() < PROBE_CACHE_TTL + { + return Ok(snap.clone()); + } + + let result = { + let accounts = self.accounts.read().unwrap(); + match accounts.get(account_id) { + Some(state) => { + let connected = state.connected.load(std::sync::atomic::Ordering::Relaxed); + let details = if connected { + state + .config + .display_name + .as_ref() + .map(|n| format!("WhatsApp: {n}")) + .or_else(|| Some("WhatsApp: connected".into())) + } else if state + .latest_qr + .read() + .ok() + .and_then(|q| q.clone()) + .is_some() + { + Some("waiting for QR scan".into()) + } else { + Some("disconnected".into()) + }; + ChannelHealthSnapshot { + connected, + account_id: account_id.to_string(), + details, + } + }, + None => ChannelHealthSnapshot { + connected: false, + account_id: account_id.to_string(), + details: Some("account not started".into()), + }, + } + }; + + if let Ok(mut cache) = self.probe_cache.write() { + cache.insert(account_id.to_string(), (result.clone(), Instant::now())); + } + + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plugin_id_and_name() { + let plugin = WhatsAppPlugin::new(PathBuf::from("/tmp/test")); + assert_eq!(plugin.id(), "whatsapp"); + assert_eq!(plugin.name(), "WhatsApp"); + } + + #[test] + fn empty_account_ids() { + let plugin = WhatsAppPlugin::new(PathBuf::from("/tmp/test")); + assert!(plugin.account_ids().is_empty()); + } + + #[test] + fn account_config_returns_none_for_unknown() { + let plugin = WhatsAppPlugin::new(PathBuf::from("/tmp/test")); + assert!(plugin.account_config("nonexistent").is_none()); + } + + #[test] + fn latest_qr_returns_none_for_unknown() { + let plugin = WhatsAppPlugin::new(PathBuf::from("/tmp/test")); + assert!(plugin.latest_qr("nonexistent").is_none()); + } +} diff --git a/crates/whatsapp/src/sled_store.rs b/crates/whatsapp/src/sled_store.rs new file mode 100644 index 00000000..2c93159e --- /dev/null +++ b/crates/whatsapp/src/sled_store.rs @@ -0,0 +1,741 @@ +//! Persistent storage backend using sled (embedded key-value database). +//! +//! Replaces `MemoryStore` so that Signal Protocol session state survives +//! restarts — users don't need to re-scan the QR code every time. +//! +//! Each account gets its own sled database at `/whatsapp//`. + +use std::{fmt::Write, path::Path, sync::atomic::AtomicI32}; + +use { + async_trait::async_trait, + wacore::{ + appstate::{hash::HashState, processor::AppStateMutationMAC}, + store::{ + error::{Result, StoreError, db_err}, + traits::*, + }, + }, +}; + +/// Hex-encode bytes without pulling in the `hex` crate. +fn hex_encode(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + let _ = write!(s, "{b:02x}"); + } + s +} + +/// Persistent store backed by sled, implementing all wacore storage traits. +pub struct SledStore { + #[allow(dead_code)] + db: sled::Db, + identities: sled::Tree, + sessions: sled::Tree, + prekeys: sled::Tree, + signed_prekeys: sled::Tree, + sender_keys: sled::Tree, + sync_keys: sled::Tree, + app_state_versions: sled::Tree, + mutation_macs: sled::Tree, + mutation_mac_indexes: sled::Tree, + device_data: sled::Tree, + device_id: AtomicI32, + skdm_recipients: sled::Tree, + lid_mappings: sled::Tree, + pn_mappings: sled::Tree, + device_list_records: sled::Tree, + sender_key_forget_marks: sled::Tree, + base_keys: sled::Tree, +} + +fn json_err(e: serde_json::Error) -> StoreError { + StoreError::Serialization(e.to_string()) +} + +impl SledStore { + /// Open or create a sled database at the given path. + pub fn open(path: impl AsRef) -> std::result::Result { + let db = sled::open(path)?; + + // Load persisted device_id counter. + let device_id_tree = db.open_tree("device_id")?; + let id_val = device_id_tree + .get(b"counter")? + .and_then(|v| v.as_ref().try_into().ok()) + .map(i32::from_le_bytes) + .unwrap_or(0); + + Ok(Self { + identities: db.open_tree("identities")?, + sessions: db.open_tree("sessions")?, + prekeys: db.open_tree("prekeys")?, + signed_prekeys: db.open_tree("signed_prekeys")?, + sender_keys: db.open_tree("sender_keys")?, + sync_keys: db.open_tree("sync_keys")?, + app_state_versions: db.open_tree("app_state_versions")?, + mutation_macs: db.open_tree("mutation_macs")?, + mutation_mac_indexes: db.open_tree("mutation_mac_indexes")?, + device_data: db.open_tree("device_data")?, + device_id: AtomicI32::new(id_val), + skdm_recipients: db.open_tree("skdm_recipients")?, + lid_mappings: db.open_tree("lid_mappings")?, + pn_mappings: db.open_tree("pn_mappings")?, + device_list_records: db.open_tree("device_list_records")?, + sender_key_forget_marks: db.open_tree("sender_key_forget_marks")?, + base_keys: db.open_tree("base_keys")?, + db, + }) + } +} + +// ============================================================================ +// SignalStore +// ============================================================================ + +#[async_trait] +impl SignalStore for SledStore { + async fn put_identity(&self, address: &str, key: [u8; 32]) -> Result<()> { + self.identities + .insert(address.as_bytes(), &key[..]) + .map_err(db_err)?; + Ok(()) + } + + async fn load_identity(&self, address: &str) -> Result>> { + Ok(self + .identities + .get(address.as_bytes()) + .map_err(db_err)? + .map(|v| v.to_vec())) + } + + async fn delete_identity(&self, address: &str) -> Result<()> { + self.identities.remove(address.as_bytes()).map_err(db_err)?; + Ok(()) + } + + async fn get_session(&self, address: &str) -> Result>> { + Ok(self + .sessions + .get(address.as_bytes()) + .map_err(db_err)? + .map(|v| v.to_vec())) + } + + async fn put_session(&self, address: &str, session: &[u8]) -> Result<()> { + self.sessions + .insert(address.as_bytes(), session) + .map_err(db_err)?; + Ok(()) + } + + async fn delete_session(&self, address: &str) -> Result<()> { + self.sessions.remove(address.as_bytes()).map_err(db_err)?; + Ok(()) + } + + async fn store_prekey(&self, id: u32, record: &[u8], uploaded: bool) -> Result<()> { + // Store as JSON: [record_bytes, uploaded_bool] + let val = serde_json::to_vec(&(record, uploaded)).map_err(json_err)?; + self.prekeys + .insert(id.to_le_bytes(), val.as_slice()) + .map_err(db_err)?; + Ok(()) + } + + async fn load_prekey(&self, id: u32) -> Result>> { + match self.prekeys.get(id.to_le_bytes()).map_err(db_err)? { + Some(v) => { + let (record, _uploaded): (Vec, bool) = + serde_json::from_slice(&v).map_err(json_err)?; + Ok(Some(record)) + }, + None => Ok(None), + } + } + + async fn remove_prekey(&self, id: u32) -> Result<()> { + self.prekeys.remove(id.to_le_bytes()).map_err(db_err)?; + Ok(()) + } + + async fn store_signed_prekey(&self, id: u32, record: &[u8]) -> Result<()> { + self.signed_prekeys + .insert(id.to_le_bytes(), record) + .map_err(db_err)?; + Ok(()) + } + + async fn load_signed_prekey(&self, id: u32) -> Result>> { + Ok(self + .signed_prekeys + .get(id.to_le_bytes()) + .map_err(db_err)? + .map(|v| v.to_vec())) + } + + async fn load_all_signed_prekeys(&self) -> Result)>> { + let mut result = Vec::new(); + for entry in self.signed_prekeys.iter() { + let (k, v) = entry.map_err(db_err)?; + if let Ok(bytes) = k.as_ref().try_into() { + let id = u32::from_le_bytes(bytes); + result.push((id, v.to_vec())); + } + } + Ok(result) + } + + async fn remove_signed_prekey(&self, id: u32) -> Result<()> { + self.signed_prekeys + .remove(id.to_le_bytes()) + .map_err(db_err)?; + Ok(()) + } + + async fn put_sender_key(&self, address: &str, record: &[u8]) -> Result<()> { + self.sender_keys + .insert(address.as_bytes(), record) + .map_err(db_err)?; + Ok(()) + } + + async fn get_sender_key(&self, address: &str) -> Result>> { + Ok(self + .sender_keys + .get(address.as_bytes()) + .map_err(db_err)? + .map(|v| v.to_vec())) + } + + async fn delete_sender_key(&self, address: &str) -> Result<()> { + self.sender_keys + .remove(address.as_bytes()) + .map_err(db_err)?; + Ok(()) + } +} + +// ============================================================================ +// AppSyncStore +// ============================================================================ + +#[async_trait] +impl AppSyncStore for SledStore { + async fn get_sync_key(&self, key_id: &[u8]) -> Result> { + match self.sync_keys.get(key_id).map_err(db_err)? { + Some(v) => Ok(Some(serde_json::from_slice(&v).map_err(json_err)?)), + None => Ok(None), + } + } + + async fn set_sync_key(&self, key_id: &[u8], key: AppStateSyncKey) -> Result<()> { + let val = serde_json::to_vec(&key).map_err(json_err)?; + self.sync_keys + .insert(key_id, val.as_slice()) + .map_err(db_err)?; + Ok(()) + } + + async fn get_version(&self, name: &str) -> Result { + match self + .app_state_versions + .get(name.as_bytes()) + .map_err(db_err)? + { + Some(v) => Ok(serde_json::from_slice(&v).map_err(json_err)?), + None => Ok(HashState::default()), + } + } + + async fn set_version(&self, name: &str, state: HashState) -> Result<()> { + let val = serde_json::to_vec(&state).map_err(json_err)?; + self.app_state_versions + .insert(name.as_bytes(), val.as_slice()) + .map_err(db_err)?; + Ok(()) + } + + async fn put_mutation_macs( + &self, + name: &str, + version: u64, + mutations: &[AppStateMutationMAC], + ) -> Result<()> { + let version_key = format!("{name}:{version}"); + let mut indexes = Vec::new(); + for mac in mutations { + let mac_key = format!("{name}:{version}:{}", hex_encode(&mac.index_mac)); + self.mutation_macs + .insert(mac_key.as_bytes(), mac.value_mac.as_slice()) + .map_err(db_err)?; + indexes.push(mac.index_mac.clone()); + } + let idx_val = serde_json::to_vec(&indexes).map_err(json_err)?; + self.mutation_mac_indexes + .insert(version_key.as_bytes(), idx_val.as_slice()) + .map_err(db_err)?; + Ok(()) + } + + async fn get_mutation_mac(&self, name: &str, index_mac: &[u8]) -> Result>> { + let prefix = format!("{name}:"); + let hex_mac = hex_encode(index_mac); + for entry in self.mutation_mac_indexes.iter() { + let (k, _) = entry.map_err(db_err)?; + let key_str = String::from_utf8_lossy(&k); + if key_str.starts_with(&prefix) { + let mac_key = format!("{key_str}:{hex_mac}"); + if let Some(value_mac) = + self.mutation_macs.get(mac_key.as_bytes()).map_err(db_err)? + { + return Ok(Some(value_mac.to_vec())); + } + } + } + Ok(None) + } + + async fn delete_mutation_macs(&self, name: &str, index_macs: &[Vec]) -> Result<()> { + for index_mac in index_macs { + let hex_mac = hex_encode(index_mac); + let prefix = format!("{name}:"); + let mut keys_to_remove = Vec::new(); + for entry in self.mutation_macs.iter() { + let (k, _) = entry.map_err(db_err)?; + let key_str = String::from_utf8_lossy(&k); + if key_str.starts_with(&prefix) && key_str.ends_with(&hex_mac) { + keys_to_remove.push(k); + } + } + for key in keys_to_remove { + self.mutation_macs.remove(key).map_err(db_err)?; + } + } + Ok(()) + } +} + +// ============================================================================ +// ProtocolStore +// ============================================================================ + +#[async_trait] +impl ProtocolStore for SledStore { + async fn get_skdm_recipients(&self, group_jid: &str) -> Result> { + match self + .skdm_recipients + .get(group_jid.as_bytes()) + .map_err(db_err)? + { + Some(v) => Ok(serde_json::from_slice(&v).map_err(json_err)?), + None => Ok(Vec::new()), + } + } + + async fn add_skdm_recipients(&self, group_jid: &str, device_jids: &[String]) -> Result<()> { + let mut current = self.get_skdm_recipients(group_jid).await?; + current.extend(device_jids.iter().cloned()); + let val = serde_json::to_vec(¤t).map_err(json_err)?; + self.skdm_recipients + .insert(group_jid.as_bytes(), val.as_slice()) + .map_err(db_err)?; + Ok(()) + } + + async fn clear_skdm_recipients(&self, group_jid: &str) -> Result<()> { + self.skdm_recipients + .remove(group_jid.as_bytes()) + .map_err(db_err)?; + Ok(()) + } + + async fn get_lid_mapping(&self, lid: &str) -> Result> { + match self.lid_mappings.get(lid.as_bytes()).map_err(db_err)? { + Some(v) => Ok(Some(serde_json::from_slice(&v).map_err(json_err)?)), + None => Ok(None), + } + } + + async fn get_pn_mapping(&self, phone: &str) -> Result> { + if let Some(lid) = self.pn_mappings.get(phone.as_bytes()).map_err(db_err)? { + let lid_str = String::from_utf8_lossy(&lid); + return self.get_lid_mapping(&lid_str).await; + } + Ok(None) + } + + async fn put_lid_mapping(&self, entry: &LidPnMappingEntry) -> Result<()> { + self.pn_mappings + .insert(entry.phone_number.as_bytes(), entry.lid.as_bytes()) + .map_err(db_err)?; + let val = serde_json::to_vec(entry).map_err(json_err)?; + self.lid_mappings + .insert(entry.lid.as_bytes(), val.as_slice()) + .map_err(db_err)?; + Ok(()) + } + + async fn get_all_lid_mappings(&self) -> Result> { + let mut result = Vec::new(); + for entry in self.lid_mappings.iter() { + let (_, v) = entry.map_err(db_err)?; + let mapping: LidPnMappingEntry = serde_json::from_slice(&v).map_err(json_err)?; + result.push(mapping); + } + Ok(result) + } + + async fn save_base_key(&self, address: &str, message_id: &str, base_key: &[u8]) -> Result<()> { + let key = format!("{address}:{message_id}"); + self.base_keys + .insert(key.as_bytes(), base_key) + .map_err(db_err)?; + Ok(()) + } + + async fn has_same_base_key( + &self, + address: &str, + message_id: &str, + current_base_key: &[u8], + ) -> Result { + let key = format!("{address}:{message_id}"); + Ok(self + .base_keys + .get(key.as_bytes()) + .map_err(db_err)? + .is_some_and(|v| v.as_ref() == current_base_key)) + } + + async fn delete_base_key(&self, address: &str, message_id: &str) -> Result<()> { + let key = format!("{address}:{message_id}"); + self.base_keys.remove(key.as_bytes()).map_err(db_err)?; + Ok(()) + } + + async fn update_device_list(&self, record: DeviceListRecord) -> Result<()> { + let val = serde_json::to_vec(&record).map_err(json_err)?; + self.device_list_records + .insert(record.user.as_bytes(), val.as_slice()) + .map_err(db_err)?; + Ok(()) + } + + async fn get_devices(&self, user: &str) -> Result> { + match self + .device_list_records + .get(user.as_bytes()) + .map_err(db_err)? + { + Some(v) => Ok(Some(serde_json::from_slice(&v).map_err(json_err)?)), + None => Ok(None), + } + } + + async fn mark_forget_sender_key(&self, group_jid: &str, participant: &str) -> Result<()> { + let key = format!("{group_jid}:{participant}"); + self.sender_key_forget_marks + .insert(key.as_bytes(), &[1u8]) + .map_err(db_err)?; + Ok(()) + } + + async fn consume_forget_marks(&self, group_jid: &str) -> Result> { + let prefix = format!("{group_jid}:"); + let mut participants = Vec::new(); + let mut keys_to_remove = Vec::new(); + + for entry in self.sender_key_forget_marks.iter() { + let (k, _) = entry.map_err(db_err)?; + let key_str = String::from_utf8_lossy(&k); + if let Some(participant) = key_str.strip_prefix(&prefix) { + participants.push(participant.to_string()); + keys_to_remove.push(k); + } + } + for key in keys_to_remove { + self.sender_key_forget_marks.remove(key).map_err(db_err)?; + } + Ok(participants) + } +} + +// ============================================================================ +// DeviceStore +// ============================================================================ + +#[async_trait] +impl DeviceStore for SledStore { + async fn save(&self, device: &wacore::store::Device) -> Result<()> { + let val = serde_json::to_vec(device).map_err(json_err)?; + self.device_data + .insert(b"device", val.as_slice()) + .map_err(db_err)?; + Ok(()) + } + + async fn load(&self) -> Result> { + match self.device_data.get(b"device").map_err(db_err)? { + Some(v) => Ok(Some(serde_json::from_slice(&v).map_err(json_err)?)), + None => Ok(None), + } + } + + async fn exists(&self) -> Result { + Ok(self.device_data.get(b"device").map_err(db_err)?.is_some()) + } + + async fn create(&self) -> Result { + let id = self + .device_id + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + // Persist the counter. + let tree = self.db.open_tree("device_id").map_err(db_err)?; + tree.insert(b"counter", &(id + 1).to_le_bytes()) + .map_err(db_err)?; + Ok(id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_store() -> SledStore { + let dir = tempfile::tempdir().unwrap(); + SledStore::open(dir.path()).unwrap() + } + + #[tokio::test] + async fn identity_roundtrip() { + let store = temp_store(); + let key = [42u8; 32]; + store + .put_identity("test@s.whatsapp.net", key) + .await + .unwrap(); + let loaded = store.load_identity("test@s.whatsapp.net").await.unwrap(); + assert_eq!(loaded, Some(key.to_vec())); + + store.delete_identity("test@s.whatsapp.net").await.unwrap(); + assert!( + store + .load_identity("test@s.whatsapp.net") + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn session_roundtrip() { + let store = temp_store(); + let data = b"session-data"; + store.put_session("addr", data).await.unwrap(); + let loaded = store.get_session("addr").await.unwrap(); + assert_eq!(loaded, Some(data.to_vec())); + assert!(store.has_session("addr").await.unwrap()); + assert!(!store.has_session("missing").await.unwrap()); + } + + #[tokio::test] + async fn device_store_roundtrip() { + let store = temp_store(); + assert!(!store.exists().await.unwrap()); + let id = store.create().await.unwrap(); + assert_eq!(id, 0); + let id2 = store.create().await.unwrap(); + assert_eq!(id2, 1); + } + + #[tokio::test] + async fn prekey_operations() { + let store = temp_store(); + store.store_prekey(1, b"pk1", false).await.unwrap(); + store.store_prekey(2, b"pk2", true).await.unwrap(); + assert_eq!(store.load_prekey(1).await.unwrap(), Some(b"pk1".to_vec())); + store.remove_prekey(1).await.unwrap(); + assert!(store.load_prekey(1).await.unwrap().is_none()); + } + + #[tokio::test] + async fn signed_prekey_operations() { + let store = temp_store(); + store.store_signed_prekey(10, b"spk10").await.unwrap(); + store.store_signed_prekey(20, b"spk20").await.unwrap(); + let all = store.load_all_signed_prekeys().await.unwrap(); + assert_eq!(all.len(), 2); + store.remove_signed_prekey(10).await.unwrap(); + let all = store.load_all_signed_prekeys().await.unwrap(); + assert_eq!(all.len(), 1); + } + + #[tokio::test] + async fn sender_key_roundtrip() { + let store = temp_store(); + store.put_sender_key("addr1", b"key1").await.unwrap(); + assert_eq!( + store.get_sender_key("addr1").await.unwrap(), + Some(b"key1".to_vec()) + ); + store.delete_sender_key("addr1").await.unwrap(); + assert!(store.get_sender_key("addr1").await.unwrap().is_none()); + } + + #[tokio::test] + async fn sync_key_roundtrip() { + let store = temp_store(); + let key = AppStateSyncKey { + key_data: vec![1, 2, 3], + fingerprint: vec![4, 5], + timestamp: 12345, + }; + store.set_sync_key(b"test-key", key.clone()).await.unwrap(); + let loaded = store.get_sync_key(b"test-key").await.unwrap(); + assert!(loaded.is_some()); + assert_eq!(loaded.unwrap().timestamp, 12345); + } + + #[tokio::test] + async fn version_roundtrip() { + let store = temp_store(); + let state = store.get_version("contacts").await.unwrap(); + assert_eq!(state.version, 0); + + let new_state = HashState { + version: 5, + ..Default::default() + }; + store.set_version("contacts", new_state).await.unwrap(); + let loaded = store.get_version("contacts").await.unwrap(); + assert_eq!(loaded.version, 5); + } + + #[tokio::test] + async fn skdm_recipients() { + let store = temp_store(); + let recips = store.get_skdm_recipients("group1").await.unwrap(); + assert!(recips.is_empty()); + + store + .add_skdm_recipients("group1", &["dev1".into(), "dev2".into()]) + .await + .unwrap(); + let recips = store.get_skdm_recipients("group1").await.unwrap(); + assert_eq!(recips.len(), 2); + + store.clear_skdm_recipients("group1").await.unwrap(); + assert!( + store + .get_skdm_recipients("group1") + .await + .unwrap() + .is_empty() + ); + } + + #[tokio::test] + async fn lid_mapping() { + let store = temp_store(); + let entry = LidPnMappingEntry { + lid: "100000012345678".into(), + phone_number: "559980000001".into(), + created_at: 1000, + updated_at: 2000, + learning_source: "usync".into(), + }; + store.put_lid_mapping(&entry).await.unwrap(); + + let by_lid = store.get_lid_mapping("100000012345678").await.unwrap(); + assert!(by_lid.is_some()); + assert_eq!(by_lid.unwrap().phone_number, "559980000001"); + + let by_pn = store.get_pn_mapping("559980000001").await.unwrap(); + assert!(by_pn.is_some()); + + let all = store.get_all_lid_mappings().await.unwrap(); + assert_eq!(all.len(), 1); + } + + #[tokio::test] + async fn base_key_operations() { + let store = temp_store(); + let key = b"base-key-data"; + store.save_base_key("addr", "msg1", key).await.unwrap(); + assert!(store.has_same_base_key("addr", "msg1", key).await.unwrap()); + assert!( + !store + .has_same_base_key("addr", "msg1", b"other") + .await + .unwrap() + ); + store.delete_base_key("addr", "msg1").await.unwrap(); + assert!(!store.has_same_base_key("addr", "msg1", key).await.unwrap()); + } + + #[tokio::test] + async fn device_list() { + let store = temp_store(); + let record = DeviceListRecord { + user: "user1".into(), + devices: vec![DeviceInfo { + device_id: 0, + key_index: Some(1), + }], + timestamp: 1000, + phash: None, + }; + store.update_device_list(record).await.unwrap(); + let loaded = store.get_devices("user1").await.unwrap(); + assert!(loaded.is_some()); + assert_eq!(loaded.unwrap().devices.len(), 1); + } + + #[tokio::test] + async fn forget_marks() { + let store = temp_store(); + store + .mark_forget_sender_key("group1", "user_a") + .await + .unwrap(); + store + .mark_forget_sender_key("group1", "user_b") + .await + .unwrap(); + let marks = store.consume_forget_marks("group1").await.unwrap(); + assert_eq!(marks.len(), 2); + let marks = store.consume_forget_marks("group1").await.unwrap(); + assert!(marks.is_empty()); + } + + #[tokio::test] + async fn persistence_survives_reopen() { + let dir = tempfile::tempdir().unwrap(); + + // Write some data. + { + let store = SledStore::open(dir.path()).unwrap(); + store + .put_identity("test@s.whatsapp.net", [1u8; 32]) + .await + .unwrap(); + store.put_session("addr", b"session-data").await.unwrap(); + let id = store.create().await.unwrap(); + assert_eq!(id, 0); + } + + // Reopen and verify. + { + let store = SledStore::open(dir.path()).unwrap(); + let identity = store.load_identity("test@s.whatsapp.net").await.unwrap(); + assert_eq!(identity, Some(vec![1u8; 32])); + let session = store.get_session("addr").await.unwrap(); + assert_eq!(session, Some(b"session-data".to_vec())); + let id = store.create().await.unwrap(); + assert_eq!(id, 1); // counter persisted + } + } +} diff --git a/crates/whatsapp/src/state.rs b/crates/whatsapp/src/state.rs new file mode 100644 index 00000000..b1c4c53b --- /dev/null +++ b/crates/whatsapp/src/state.rs @@ -0,0 +1,200 @@ +use std::{ + collections::{HashMap, VecDeque}, + sync::{Arc, Mutex, RwLock}, +}; + +use {tokio_util::sync::CancellationToken, whatsapp_rust::client::Client}; + +use moltis_channels::{ChannelEventSink, message_log::MessageLog}; + +use crate::{config::WhatsAppAccountConfig, otp::OtpState}; + +/// Maximum number of sent message IDs to track for self-chat loop detection. +const SENT_IDS_CAPACITY: usize = 256; + +/// Invisible Unicode watermark appended to every bot-sent message. +/// +/// A sequence of ZWJ (U+200D) and ZWNJ (U+200C) characters that is: +/// - Invisible to the user +/// - Preserved by WhatsApp (both are required for emoji/script rendering) +/// - Linguistically meaningless as an alternating pattern +/// +/// Used as a secondary self-chat loop detection alongside message-ID tracking. +pub(crate) const BOT_WATERMARK: &str = "\u{200D}\u{200C}\u{200D}\u{200C}"; + +/// Shared account state map. +pub type AccountStateMap = Arc>>; + +/// Per-account runtime state. +pub struct AccountState { + pub client: Arc, + pub account_id: String, + pub config: WhatsAppAccountConfig, + pub cancel: CancellationToken, + pub message_log: Option>, + pub event_sink: Option>, + /// Latest QR code data for the pairing flow (updated every ~20s). + pub latest_qr: RwLock>, + /// Whether the client is currently connected. + pub connected: std::sync::atomic::AtomicBool, + /// In-memory OTP challenges for self-approval (std::sync::Mutex because + /// all OTP operations are synchronous HashMap lookups, never held across + /// `.await` points). + pub otp: Mutex, + /// Recently sent message IDs, used to distinguish bot echoes from user + /// messages in self-chat. When the bot sends a message, the ID is recorded + /// here. Incoming `is_from_me` messages whose ID matches are bot echoes + /// and get skipped; non-matching ones are genuine user messages from + /// another device (phone, WhatsApp Web) and get processed. + pub(crate) recent_sent_ids: Mutex>, +} + +impl AccountState { + /// Record a message ID that was sent by the bot, for self-chat loop detection. + pub fn record_sent_id(&self, id: &str) { + let mut ids = self.recent_sent_ids.lock().unwrap(); + if ids.len() >= SENT_IDS_CAPACITY { + ids.pop_front(); + } + ids.push_back(id.to_string()); + } + + /// Check if a message ID was recently sent by the bot. + pub fn was_sent_by_us(&self, id: &str) -> bool { + let ids = self.recent_sent_ids.lock().unwrap(); + ids.iter().any(|sent_id| sent_id == id) + } + + /// Send a WhatsApp message and record its ID for self-chat loop detection. + /// Appends an invisible watermark to text messages for secondary loop detection. + pub async fn send_message( + &self, + to: wacore_binary::jid::Jid, + mut msg: waproto::whatsapp::Message, + ) -> Result<(), anyhow::Error> { + watermark_message(&mut msg); + let msg_id = self.client.send_message(to, msg).await?; + self.record_sent_id(&msg_id); + Ok(()) + } +} + +/// Append the invisible bot watermark to a message's text content. +pub(crate) fn watermark_message(msg: &mut waproto::whatsapp::Message) { + if let Some(ref mut text) = msg.conversation { + text.push_str(BOT_WATERMARK); + } + if let Some(ref mut ext) = msg.extended_text_message + && let Some(ref mut text) = ext.text + { + text.push_str(BOT_WATERMARK); + } +} + +/// Check if a message text contains the bot watermark. +pub(crate) fn has_bot_watermark(text: &str) -> bool { + text.ends_with(BOT_WATERMARK) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Helper to create a bare-bones sent-ID tracker (same data structure as AccountState). + fn new_tracker() -> Mutex> { + Mutex::new(VecDeque::new()) + } + + fn record(tracker: &Mutex>, id: &str) { + let mut ids = tracker.lock().unwrap(); + if ids.len() >= SENT_IDS_CAPACITY { + ids.pop_front(); + } + ids.push_back(id.to_string()); + } + + fn was_sent(tracker: &Mutex>, id: &str) -> bool { + let ids = tracker.lock().unwrap(); + ids.iter().any(|sent_id| sent_id == id) + } + + #[test] + fn sent_id_tracking_basic() { + let tracker = new_tracker(); + assert!(!was_sent(&tracker, "msg1")); + record(&tracker, "msg1"); + assert!(was_sent(&tracker, "msg1")); + assert!(!was_sent(&tracker, "msg2")); + } + + #[test] + fn sent_id_tracking_evicts_oldest() { + let tracker = new_tracker(); + for i in 0..SENT_IDS_CAPACITY { + record(&tracker, &format!("msg{i}")); + } + // All 256 IDs should be present. + assert!(was_sent(&tracker, "msg0")); + assert!(was_sent(&tracker, &format!("msg{}", SENT_IDS_CAPACITY - 1))); + + // Adding one more should evict the oldest. + record(&tracker, "overflow"); + assert!(!was_sent(&tracker, "msg0")); + assert!(was_sent(&tracker, "msg1")); + assert!(was_sent(&tracker, "overflow")); + } + + #[test] + fn sent_id_tracking_no_false_positives() { + let tracker = new_tracker(); + record(&tracker, "abc123"); + assert!(!was_sent(&tracker, "abc12")); + assert!(!was_sent(&tracker, "abc1234")); + assert!(!was_sent(&tracker, "ABC123")); + } + + #[test] + fn watermark_appended_to_conversation() { + let mut msg = waproto::whatsapp::Message { + conversation: Some("Hello".into()), + ..Default::default() + }; + watermark_message(&mut msg); + assert_eq!( + msg.conversation.as_deref(), + Some("Hello\u{200D}\u{200C}\u{200D}\u{200C}") + ); + assert!(has_bot_watermark(msg.conversation.as_deref().unwrap())); + } + + #[test] + fn watermark_appended_to_extended_text() { + let mut msg = waproto::whatsapp::Message { + extended_text_message: Some(Box::new( + waproto::whatsapp::message::ExtendedTextMessage { + text: Some("Hello".into()), + ..Default::default() + }, + )), + ..Default::default() + }; + watermark_message(&mut msg); + let text = msg.extended_text_message.unwrap().text.unwrap(); + assert!(has_bot_watermark(&text)); + } + + #[test] + fn watermark_not_present_in_plain_text() { + assert!(!has_bot_watermark("Hello world")); + assert!(!has_bot_watermark("")); + assert!(!has_bot_watermark("some \u{200D} text")); + } + + #[test] + fn watermark_skips_message_without_text() { + let mut msg = waproto::whatsapp::Message::default(); + watermark_message(&mut msg); + assert!(msg.conversation.is_none()); + assert!(msg.extended_text_message.is_none()); + } +} diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 31447a7f..47f6b57b 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -22,6 +22,7 @@ - [Local LLMs](local-llm.md) - [Sandbox](sandbox.md) - [Voice](voice.md) +- [WhatsApp](whatsapp.md) - [Browser Automation](browser-automation.md) - [Session State](session-state.md) - [Session Branching](session-branching.md) diff --git a/docs/src/whatsapp.md b/docs/src/whatsapp.md new file mode 100644 index 00000000..2a21304b --- /dev/null +++ b/docs/src/whatsapp.md @@ -0,0 +1,381 @@ +# WhatsApp Channel + +Moltis supports WhatsApp as a messaging channel using the WhatsApp Linked Devices +protocol. Your WhatsApp account connects as a linked device (like WhatsApp Web), +so no separate phone number or WhatsApp Business API is needed — you pair your +existing personal or business WhatsApp by scanning a QR code. + +## How It Works + +``` +┌────────────────┐ QR pair ┌─────────────────┐ Signal ┌──────────────┐ +│ Your Phone │ ──────────► │ Moltis Gateway │ ◄────────► │ WhatsApp │ +│ (WhatsApp) │ │ (linked device) │ │ Servers │ +└────────────────┘ └─────────────────┘ └──────────────┘ + │ + ▼ + ┌─────────────────┐ + │ LLM Provider │ + │ (Claude, GPT…) │ + └─────────────────┘ +``` + +1. Moltis registers as a **linked device** on your WhatsApp account +2. Messages sent to your WhatsApp number arrive at both your phone and Moltis +3. Moltis processes inbound messages through the configured LLM +4. The LLM reply is sent back through your WhatsApp account + +```admonish info title="Dedicated vs Personal Number" +**Dedicated number (recommended):** Use a separate phone with its own WhatsApp +account. All messages to that number go to the bot. Clean separation, no +accidental replies to personal contacts. + +**Personal number (self-chat):** Use your own WhatsApp account and message +yourself via WhatsApp's "Message Yourself" feature. Moltis automatically +detects self-chat and prevents reply loops. Convenient for personal use. +Note that Moltis (as a linked device) sees all your incoming messages — +whether it *responds* is governed by access control (see below). +``` + +## Feature Flag + +WhatsApp is behind the `whatsapp` cargo feature, enabled by default: + +```toml +# crates/cli/Cargo.toml +[features] +default = ["whatsapp", ...] +whatsapp = ["moltis-gateway/whatsapp"] +``` + +When disabled, all WhatsApp code is compiled out — no QR code library, no +Signal Protocol store, no WhatsApp event handlers. + +## Quick Start (Web UI) + +The fastest way to connect WhatsApp: + +1. Start Moltis: `moltis serve` +2. Open the web UI and navigate to **Settings > Channels** +3. Click **+ Add Channel** > **WhatsApp** +4. Enter an **Account ID** (any name you like, e.g. `my-whatsapp`) +5. Choose a **DM Policy** (Open, Allowlist, or Disabled) +6. Optionally select a default **Model** +7. Click **Start Pairing** — a QR code appears +8. On your phone: **WhatsApp > Settings > Linked Devices > Link a Device** +9. Scan the QR code +10. The modal shows "Connected" with your phone's display name + +That's it — messages to your WhatsApp account are now processed by Moltis. + +```admonish tip +The QR code refreshes automatically every ~20 seconds. If it expires before +you scan it, a new one appears without any action needed. +``` + +## Quick Start (Config File) + +You can also configure WhatsApp accounts in `moltis.toml`. This is useful for +automated deployments or when you want to pre-configure settings before pairing. + +```toml +# ~/.moltis/moltis.toml + +[channels.whatsapp."my-whatsapp"] +dm_policy = "open" +model = "anthropic/claude-sonnet-4-20250514" +model_provider = "anthropic" +``` + +Start Moltis and the account will begin the pairing process. The QR code is +printed to the terminal and also available via the web UI. Once paired, the +config file is updated with: + +```toml +[channels.whatsapp."my-whatsapp"] +paired = true +display_name = "John's iPhone" +phone_number = "+15551234567" +dm_policy = "open" +model = "anthropic/claude-sonnet-4-20250514" +model_provider = "anthropic" +``` + +## Configuration Reference + +Each WhatsApp account is a named entry under `[channels.whatsapp]`: + +```toml +[channels.whatsapp.""] +``` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `paired` | bool | `false` | Whether QR code pairing is complete (auto-set) | +| `display_name` | string | — | Phone name after pairing (auto-populated) | +| `phone_number` | string | — | Phone number after pairing (auto-populated) | +| `store_path` | string | — | Custom path to sled store; defaults to `~/.moltis/whatsapp//` | +| `model` | string | — | Default LLM model ID for this account | +| `model_provider` | string | — | Provider name for the model | +| `dm_policy` | string | `"open"` | DM access policy: `"open"`, `"allowlist"`, or `"disabled"` | +| `group_policy` | string | `"open"` | Group access policy: `"open"`, `"allowlist"`, or `"disabled"` | +| `allowlist` | array | `[]` | Users allowed to DM (usernames or phone numbers) | +| `group_allowlist` | array | `[]` | Group JIDs allowed for bot responses | +| `otp_self_approval` | bool | `true` | Allow non-allowlisted users to self-approve via OTP | +| `otp_cooldown_secs` | int | `300` | Cooldown seconds after 3 failed OTP attempts | + +### Full Example + +```toml +[channels.whatsapp."personal"] +paired = true +display_name = "John's iPhone" +phone_number = "+15551234567" +model = "anthropic/claude-sonnet-4-20250514" +model_provider = "anthropic" +dm_policy = "allowlist" +allowlist = ["alice", "bob", "+15559876543"] +group_policy = "disabled" +otp_self_approval = true +otp_cooldown_secs = 300 + +[channels.whatsapp."work-bot"] +paired = true +dm_policy = "open" +group_policy = "allowlist" +group_allowlist = ["120363456789@g.us"] +model = "openai/gpt-4.1" +model_provider = "openai" +``` + +## Access Control + +WhatsApp uses the same access control model as Telegram channels. + +### DM Policies + +| Policy | Behavior | +|--------|----------| +| `open` | Anyone who messages your WhatsApp can chat with the bot | +| `allowlist` | Only users on the allowlist get responses; others get an OTP challenge | +| `disabled` | All DMs are silently ignored | + +### Group Policies + +| Policy | Behavior | +|--------|----------| +| `open` | Bot responds in all groups it's part of | +| `allowlist` | Bot only responds in groups on the `group_allowlist` | +| `disabled` | Bot ignores all group messages | + +### OTP Self-Approval + +When `dm_policy = "allowlist"` and `otp_self_approval = true` (the default), +users not on the allowlist can request access: + +1. User sends any message to the bot +2. Bot replies: *"Please reply with the 6-digit code to verify access"* +3. The OTP code appears in the **Senders** tab of the web UI +4. User replies with the code +5. If correct, user is permanently added to the allowlist + +After 3 wrong attempts, a cooldown period kicks in (default 5 minutes). +You can also approve or deny users directly from the Senders tab without +waiting for OTP verification. + +```admonish tip +Set `otp_self_approval = false` if you want to manually approve every new +user from the web UI instead of letting them self-approve. +``` + +### Using Your Personal Number Safely + +When Moltis is linked to your personal WhatsApp, it sees **every** incoming +message — from friends, family, groups, everyone. The key question is: who +does the bot *respond* to? + +**Self-chat always works.** Messages you send to yourself (via "Message +Yourself") bypass access control entirely. You are the account owner, so +you're always authorized regardless of `dm_policy` settings. + +**Other people's messages follow `dm_policy`.** If you want Moltis to only +respond to your self-chat and ignore everyone else: + +```toml +[channels.whatsapp."personal"] +dm_policy = "disabled" # Ignore all DMs from other people +group_policy = "disabled" # Ignore all group messages +``` + +This is the safest configuration for personal use — the bot only responds +when you message yourself. + +If you want to selectively allow certain contacts: + +```toml +[channels.whatsapp."personal"] +dm_policy = "allowlist" +allowlist = ["alice", "bob"] # Only these people get bot responses +group_policy = "disabled" +``` + +```admonish warning title="Default is Open" +The default `dm_policy` is `"open"`, which means **everyone** who messages +your WhatsApp will get a bot response. If you're using your personal number, +change this to `"disabled"` or `"allowlist"` before pairing. +``` + +## Session Persistence + +WhatsApp uses the Signal Protocol for end-to-end encryption. The encryption +keys and session state are stored in a **sled database** at: + +``` +~/.moltis/whatsapp// +``` + +This means: + +- **No re-pairing after restart** — the linked device session survives process + restarts, server reboots, and upgrades +- **One store per account** — multiple WhatsApp accounts each get their own + isolated database +- **Custom path** — set `store_path` in config to use a different location + (useful for Docker volumes or shared storage) + +```admonish warning +Do not delete the sled store directory while Moltis is running. If you need +to re-pair, stop Moltis first, then delete the directory and restart. +``` + +## Self-Chat + +Moltis automatically supports WhatsApp's "Message Yourself" feature. When you +send a message to yourself, the bot processes it as a regular inbound message +and replies in the same chat. + +This is useful for: +- **Personal assistant** — chat with your AI without a dedicated phone number +- **Testing** — verify the bot works before sharing with others +- **Quick notes** — send yourself reminders that the AI processes + +### Loop Prevention + +When the bot replies to your self-chat, WhatsApp delivers that reply back as +an incoming message (since it's your own chat). Moltis uses two mechanisms to +prevent infinite reply loops: + +1. **Message ID tracking**: Every message the bot sends is recorded in a + bounded ring buffer (256 entries). Incoming `is_from_me` messages whose + ID matches a tracked send are recognized as bot echoes and skipped. + +2. **Invisible watermark**: An invisible Unicode sequence (zero-width joiners) + is appended to every bot-sent text message. If an incoming message contains + this watermark, it's recognized as a bot echo even if the message ID wasn't + tracked (e.g. after a restart). + +Both checks are automatic — no configuration needed. + +## Media Handling + +WhatsApp supports rich media messages. Moltis handles each type: + +| Message Type | Handling | +|--------------|----------| +| **Text** | Dispatched directly to the LLM | +| **Image** | Downloaded, optimized for LLM consumption (resized if needed), sent as attachment | +| **Voice** | Downloaded and transcribed via STT (if configured); falls back to text guidance | +| **Audio** | Same as voice, but classified separately (non-PTT audio files) | +| **Video** | Thumbnail extracted and sent as image attachment with caption | +| **Document** | Caption and filename/MIME metadata dispatched as text | +| **Location** | Resolves pending location tool requests, or dispatches coordinates to LLM | + +```admonish info title="Voice Transcription" +Voice message transcription requires an STT provider to be configured. +See [Voice Services](voice.md) for setup instructions. Without STT, +the bot replies asking the user to send a text message instead. +``` + +## Managing Channels in the Web UI + +### Channels Tab + +The Channels page shows all connected accounts across all channel types +(Telegram, WhatsApp). Each card displays: + +- **Status badge**: `connected`, `pairing`, or `disconnected` +- **Display name**: Phone name from WhatsApp (after pairing) +- **Sender summary**: List of recent senders with message counts +- **Edit / Remove** buttons + +### Adding a WhatsApp Channel + +1. Click **+ Add Channel** > **WhatsApp** +2. Fill in the account ID, DM policy, and optional model +3. Click **Start Pairing** +4. Scan the QR code on your phone +5. Wait for the "Connected" confirmation + +### Editing a Channel + +Click **Edit** on a channel card to modify: +- DM and group policies +- Allowlist entries +- Default model + +Changes take effect immediately — no restart needed. + +### Senders Tab + +Switch to the **Senders** tab to see everyone who has messaged the bot: + +- Filter by account using the dropdown +- See message counts, last activity, and access status +- **Approve** or **Deny** users directly +- View pending OTP challenges with the code displayed + +## Troubleshooting + +### QR Code Not Appearing + +- Ensure the `whatsapp` feature is enabled (it is by default) +- Check terminal output for errors — the QR code is also printed to stdout +- Verify the sled store directory is writable: `~/.moltis/whatsapp/` + +### "Logged Out" After Restart + +- This usually means the sled store was corrupted or deleted +- Check that `~/.moltis/whatsapp//` exists and has data files +- Re-pair by removing the directory and restarting: the pairing flow starts again + +### Bot Not Responding to Messages + +- Check `dm_policy` — if set to `allowlist`, only listed users get responses +- Check `group_policy` — if set to `disabled`, group messages are ignored +- Look at the **Senders** tab to see if the user is denied or pending OTP +- Check terminal logs for access control decisions + +### Self-Chat Not Working + +- WhatsApp's "Message Yourself" chat must be used (not a group with only yourself) +- The bot needs to be connected and the account paired +- If you just restarted, the watermark-based detection handles messages that + arrive before the message ID buffer is rebuilt + +## Code Structure + +``` +crates/whatsapp/ +├── src/ +│ ├── lib.rs # Crate entry, WhatsAppPlugin +│ ├── config.rs # WhatsAppAccountConfig +│ ├── connection.rs # Bot startup, sled store, event loop +│ ├── handlers.rs # Event routing, message handling, media +│ ├── outbound.rs # WhatsAppOutbound (ChannelOutbound impl) +│ ├── state.rs # AccountState, loop detection, watermark +│ ├── access.rs # DM/group access control +│ ├── otp.rs # OTP challenge/verification +│ ├── plugin.rs # ChannelPlugin trait impl +│ ├── sled_store.rs # Persistent Signal Protocol store +│ └── memory_store.rs # In-memory store (tests/fallback) +``` From e6de99147d4fd2d943bb4cacb934f44395c32b42 Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Mon, 9 Feb 2026 22:37:41 -0800 Subject: [PATCH 02/16] fix(whatsapp): replace unwrap with unwrap_or_else for poisoned lock recovery Adapt to workspace-level clippy::unwrap_used and clippy::expect_used deny lints added in v0.5.0. Replace all 20 .unwrap() calls on RwLock/Mutex guards with .unwrap_or_else(|e| e.into_inner()) to gracefully recover from poisoned locks, matching the telegram crate pattern. --- crates/whatsapp/src/connection.rs | 2 +- crates/whatsapp/src/handlers.rs | 12 ++++++------ crates/whatsapp/src/outbound.rs | 4 ++-- crates/whatsapp/src/plugin.rs | 18 +++++++++--------- crates/whatsapp/src/state.rs | 10 ++++++++-- 5 files changed, 26 insertions(+), 20 deletions(-) diff --git a/crates/whatsapp/src/connection.rs b/crates/whatsapp/src/connection.rs index 7ceaf79f..35c677ad 100644 --- a/crates/whatsapp/src/connection.rs +++ b/crates/whatsapp/src/connection.rs @@ -96,7 +96,7 @@ pub async fn start_connection( // Insert into the shared map. { - let mut map = accounts.write().unwrap(); + let mut map = accounts.write().unwrap_or_else(|e| e.into_inner()); map.insert(account_id.clone(), AccountState { client: Arc::clone(&client), account_id: account_id.clone(), diff --git a/crates/whatsapp/src/handlers.rs b/crates/whatsapp/src/handlers.rs index 7c5e5aaa..32731906 100644 --- a/crates/whatsapp/src/handlers.rs +++ b/crates/whatsapp/src/handlers.rs @@ -707,11 +707,11 @@ async fn handle_otp_flow( state: &AccountState, ) { let has_pending = { - let accts = accounts.read().unwrap(); + let accts = accounts.read().unwrap_or_else(|e| e.into_inner()); accts .get(account_id) .map(|s| { - let otp = s.otp.lock().unwrap(); + let otp = s.otp.lock().unwrap_or_else(|e| e.into_inner()); otp.has_pending(peer_id) }) .unwrap_or(false) @@ -725,10 +725,10 @@ async fn handle_otp_flow( } let result = { - let accts = accounts.read().unwrap(); + let accts = accounts.read().unwrap_or_else(|e| e.into_inner()); match accts.get(account_id) { Some(s) => { - let mut otp = s.otp.lock().unwrap(); + let mut otp = s.otp.lock().unwrap_or_else(|e| e.into_inner()); otp.verify(peer_id, trimmed) }, None => return, @@ -792,10 +792,10 @@ async fn handle_otp_flow( // No pending challenge — initiate one. let init_result = { - let accts = accounts.read().unwrap(); + let accts = accounts.read().unwrap_or_else(|e| e.into_inner()); match accts.get(account_id) { Some(s) => { - let mut otp = s.otp.lock().unwrap(); + let mut otp = s.otp.lock().unwrap_or_else(|e| e.into_inner()); otp.initiate( peer_id, username.map(String::from), diff --git a/crates/whatsapp/src/outbound.rs b/crates/whatsapp/src/outbound.rs index b9e2abf5..c9619a45 100644 --- a/crates/whatsapp/src/outbound.rs +++ b/crates/whatsapp/src/outbound.rs @@ -16,7 +16,7 @@ impl WhatsAppOutbound { &self, account_id: &str, ) -> Result> { - let accounts = self.accounts.read().unwrap(); + let accounts = self.accounts.read().unwrap_or_else(|e| e.into_inner()); accounts .get(account_id) .map(|s| std::sync::Arc::clone(&s.client)) @@ -25,7 +25,7 @@ impl WhatsAppOutbound { /// Record a sent message ID for self-chat loop detection. fn record_sent_id(&self, account_id: &str, msg_id: &str) { - let accounts = self.accounts.read().unwrap(); + let accounts = self.accounts.read().unwrap_or_else(|e| e.into_inner()); if let Some(state) = accounts.get(account_id) { state.record_sent_id(msg_id); } diff --git a/crates/whatsapp/src/plugin.rs b/crates/whatsapp/src/plugin.rs index 7cb05715..50818a1d 100644 --- a/crates/whatsapp/src/plugin.rs +++ b/crates/whatsapp/src/plugin.rs @@ -69,13 +69,13 @@ impl WhatsAppPlugin { /// List all active account IDs. pub fn account_ids(&self) -> Vec { - let accounts = self.accounts.read().unwrap(); + let accounts = self.accounts.read().unwrap_or_else(|e| e.into_inner()); accounts.keys().cloned().collect() } /// Get the config for a specific account (serialized to JSON). pub fn account_config(&self, account_id: &str) -> Option { - let accounts = self.accounts.read().unwrap(); + let accounts = self.accounts.read().unwrap_or_else(|e| e.into_inner()); accounts .get(account_id) .and_then(|s| serde_json::to_value(&s.config).ok()) @@ -83,7 +83,7 @@ impl WhatsAppPlugin { /// Get the latest QR code data for a specific account. pub fn latest_qr(&self, account_id: &str) -> Option { - let accounts = self.accounts.read().unwrap(); + let accounts = self.accounts.read().unwrap_or_else(|e| e.into_inner()); accounts .get(account_id) .and_then(|s| s.latest_qr.read().ok()?.clone()) @@ -97,7 +97,7 @@ impl WhatsAppPlugin { config: serde_json::Value, ) -> anyhow::Result<()> { let wa_config: WhatsAppAccountConfig = serde_json::from_value(config)?; - let mut accounts = self.accounts.write().unwrap(); + let mut accounts = self.accounts.write().unwrap_or_else(|e| e.into_inner()); if let Some(state) = accounts.get_mut(account_id) { state.config = wa_config; Ok(()) @@ -108,11 +108,11 @@ impl WhatsAppPlugin { /// List pending OTP challenges for a specific account. pub fn pending_otp_challenges(&self, account_id: &str) -> Vec { - let accounts = self.accounts.read().unwrap(); + let accounts = self.accounts.read().unwrap_or_else(|e| e.into_inner()); accounts .get(account_id) .map(|s| { - let otp = s.otp.lock().unwrap(); + let otp = s.otp.lock().unwrap_or_else(|e| e.into_inner()); otp.list_pending() }) .unwrap_or_default() @@ -149,14 +149,14 @@ impl ChannelPlugin for WhatsAppPlugin { async fn stop_account(&mut self, account_id: &str) -> Result<()> { let cancel = { - let accounts = self.accounts.read().unwrap(); + let accounts = self.accounts.read().unwrap_or_else(|e| e.into_inner()); accounts.get(account_id).map(|s| s.cancel.clone()) }; if let Some(cancel) = cancel { info!(account_id, "stopping WhatsApp account"); cancel.cancel(); - let mut accounts = self.accounts.write().unwrap(); + let mut accounts = self.accounts.write().unwrap_or_else(|e| e.into_inner()); accounts.remove(account_id); } else { warn!(account_id, "WhatsApp account not found"); @@ -186,7 +186,7 @@ impl ChannelStatus for WhatsAppPlugin { } let result = { - let accounts = self.accounts.read().unwrap(); + let accounts = self.accounts.read().unwrap_or_else(|e| e.into_inner()); match accounts.get(account_id) { Some(state) => { let connected = state.connected.load(std::sync::atomic::Ordering::Relaxed); diff --git a/crates/whatsapp/src/state.rs b/crates/whatsapp/src/state.rs index b1c4c53b..1f8aba90 100644 --- a/crates/whatsapp/src/state.rs +++ b/crates/whatsapp/src/state.rs @@ -52,7 +52,10 @@ pub struct AccountState { impl AccountState { /// Record a message ID that was sent by the bot, for self-chat loop detection. pub fn record_sent_id(&self, id: &str) { - let mut ids = self.recent_sent_ids.lock().unwrap(); + let mut ids = self + .recent_sent_ids + .lock() + .unwrap_or_else(|e| e.into_inner()); if ids.len() >= SENT_IDS_CAPACITY { ids.pop_front(); } @@ -61,7 +64,10 @@ impl AccountState { /// Check if a message ID was recently sent by the bot. pub fn was_sent_by_us(&self, id: &str) -> bool { - let ids = self.recent_sent_ids.lock().unwrap(); + let ids = self + .recent_sent_ids + .lock() + .unwrap_or_else(|e| e.into_inner()); ids.iter().any(|sent_id| sent_id == id) } From 0c5310ce09659cf98618cfbdcffea851db9bd6ec Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Tue, 10 Feb 2026 18:23:08 -0800 Subject: [PATCH 03/16] chore: sync Cargo.lock --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 6cf100ce..937d7541 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5121,7 +5121,7 @@ dependencies = [ [[package]] name = "moltis-whatsapp" -version = "0.5.0" +version = "0.6.1" dependencies = [ "anyhow", "async-trait", From 6530c846312ca3eda80e402fe5a1f0f942fd2570 Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Tue, 10 Feb 2026 18:24:24 -0800 Subject: [PATCH 04/16] style: run rustfmt --- crates/agents/src/model.rs | 13 +- crates/agents/src/providers/github_copilot.rs | 13 +- crates/agents/src/providers/mod.rs | 126 ++++----- crates/agents/src/providers/openai_codex.rs | 26 +- crates/agents/src/providers/openai_compat.rs | 7 +- crates/agents/src/tool_registry.rs | 66 ++--- crates/config/src/validate.rs | 11 +- crates/cron/src/service.rs | 11 +- crates/gateway/src/auth_webauthn.rs | 16 +- crates/gateway/src/channel_events.rs | 52 ++-- crates/gateway/src/methods.rs | 8 +- crates/gateway/src/provider_setup.rs | 68 ++--- crates/gateway/src/request_throttle.rs | 14 +- crates/gateway/src/server.rs | 59 ++-- crates/gateway/src/state.rs | 9 +- crates/mcp/src/manager.rs | 11 +- crates/mcp/src/registry.rs | 50 ++-- crates/metrics/src/store.rs | 18 +- crates/oauth/src/defaults.rs | 79 +++--- crates/telegram/src/handlers.rs | 29 +- crates/telegram/src/otp.rs | 9 +- crates/tools/src/sandbox.rs | 12 +- crates/tools/src/sandbox_packages.rs | 261 ++++++++---------- crates/tools/src/web_fetch.rs | 11 +- crates/tools/src/web_search.rs | 11 +- 25 files changed, 409 insertions(+), 581 deletions(-) diff --git a/crates/agents/src/model.rs b/crates/agents/src/model.rs index 5b9f5a2c..4618ae0a 100644 --- a/crates/agents/src/model.rs +++ b/crates/agents/src/model.rs @@ -473,14 +473,11 @@ mod tests { #[test] fn to_openai_assistant_with_tools() { - let msg = ChatMessage::assistant_with_tools( - Some("thinking".into()), - vec![ToolCall { - id: "call_1".into(), - name: "exec".into(), - arguments: serde_json::json!({"cmd": "ls"}), - }], - ); + let msg = ChatMessage::assistant_with_tools(Some("thinking".into()), vec![ToolCall { + id: "call_1".into(), + name: "exec".into(), + arguments: serde_json::json!({"cmd": "ls"}), + }]); let val = msg.to_openai_value(); assert_eq!(val["role"], "assistant"); assert_eq!(val["content"], "thinking"); diff --git a/crates/agents/src/providers/github_copilot.rs b/crates/agents/src/providers/github_copilot.rs index 69928566..8d5ebded 100644 --- a/crates/agents/src/providers/github_copilot.rs +++ b/crates/agents/src/providers/github_copilot.rs @@ -235,14 +235,11 @@ async fn fetch_valid_copilot_token( } let copilot_resp: CopilotTokenResponse = resp.json().await?; - let _ = token_store.save( - "github-copilot-api", - &OAuthTokens { - access_token: Secret::new(copilot_resp.token.clone()), - refresh_token: None, - expires_at: Some(copilot_resp.expires_at), - }, - ); + let _ = token_store.save("github-copilot-api", &OAuthTokens { + access_token: Secret::new(copilot_resp.token.clone()), + refresh_token: None, + expires_at: Some(copilot_resp.expires_at), + }); Ok(copilot_resp.token) } diff --git a/crates/agents/src/providers/mod.rs b/crates/agents/src/providers/mod.rs index 21575768..04b32116 100644 --- a/crates/agents/src/providers/mod.rs +++ b/crates/agents/src/providers/mod.rs @@ -1552,13 +1552,12 @@ mod tests { #[test] fn mistral_registers_with_api_key() { let mut config = ProvidersConfig::default(); - config.providers.insert( - "mistral".into(), - moltis_config::schema::ProviderEntry { + config + .providers + .insert("mistral".into(), moltis_config::schema::ProviderEntry { api_key: Some(secrecy::Secret::new("sk-test-mistral".into())), ..Default::default() - }, - ); + }); let reg = ProviderRegistry::from_env_with_config(&config); // Should have registered Mistral models @@ -1580,13 +1579,12 @@ mod tests { #[test] fn cerebras_registers_with_api_key() { let mut config = ProvidersConfig::default(); - config.providers.insert( - "cerebras".into(), - moltis_config::schema::ProviderEntry { + config + .providers + .insert("cerebras".into(), moltis_config::schema::ProviderEntry { api_key: Some(secrecy::Secret::new("sk-test-cerebras".into())), ..Default::default() - }, - ); + }); let reg = ProviderRegistry::from_env_with_config(&config); let cerebras_models: Vec<_> = reg @@ -1600,13 +1598,12 @@ mod tests { #[test] fn minimax_registers_with_api_key() { let mut config = ProvidersConfig::default(); - config.providers.insert( - "minimax".into(), - moltis_config::schema::ProviderEntry { + config + .providers + .insert("minimax".into(), moltis_config::schema::ProviderEntry { api_key: Some(secrecy::Secret::new("sk-test-minimax".into())), ..Default::default() - }, - ); + }); let reg = ProviderRegistry::from_env_with_config(&config); assert!(reg.list_models().iter().any(|m| m.provider == "minimax")); @@ -1615,13 +1612,12 @@ mod tests { #[test] fn moonshot_registers_with_api_key() { let mut config = ProvidersConfig::default(); - config.providers.insert( - "moonshot".into(), - moltis_config::schema::ProviderEntry { + config + .providers + .insert("moonshot".into(), moltis_config::schema::ProviderEntry { api_key: Some(secrecy::Secret::new("sk-test-moonshot".into())), ..Default::default() - }, - ); + }); let reg = ProviderRegistry::from_env_with_config(&config); assert!(reg.list_models().iter().any(|m| m.provider == "moonshot")); @@ -1631,13 +1627,12 @@ mod tests { fn openrouter_requires_model_in_config() { // OpenRouter has no default models — without a model in config it registers nothing. let mut config = ProvidersConfig::default(); - config.providers.insert( - "openrouter".into(), - moltis_config::schema::ProviderEntry { + config + .providers + .insert("openrouter".into(), moltis_config::schema::ProviderEntry { api_key: Some(secrecy::Secret::new("sk-test-or".into())), ..Default::default() - }, - ); + }); let reg = ProviderRegistry::from_env_with_config(&config); assert!(!reg.list_models().iter().any(|m| m.provider == "openrouter")); @@ -1646,14 +1641,13 @@ mod tests { #[test] fn openrouter_registers_with_model_in_config() { let mut config = ProvidersConfig::default(); - config.providers.insert( - "openrouter".into(), - moltis_config::schema::ProviderEntry { + config + .providers + .insert("openrouter".into(), moltis_config::schema::ProviderEntry { api_key: Some(secrecy::Secret::new("sk-test-or".into())), model: Some("anthropic/claude-3-haiku".into()), ..Default::default() - }, - ); + }); let reg = ProviderRegistry::from_env_with_config(&config); let or_models: Vec<_> = reg @@ -1669,13 +1663,12 @@ mod tests { fn ollama_registers_without_api_key_env() { // Ollama should use a dummy key if no env var is set. let mut config = ProvidersConfig::default(); - config.providers.insert( - "ollama".into(), - moltis_config::schema::ProviderEntry { + config + .providers + .insert("ollama".into(), moltis_config::schema::ProviderEntry { model: Some("llama3".into()), ..Default::default() - }, - ); + }); let reg = ProviderRegistry::from_env_with_config(&config); assert!(reg.list_models().iter().any(|m| m.provider == "ollama")); @@ -1685,13 +1678,12 @@ mod tests { #[test] fn venice_requires_model_in_config() { let mut config = ProvidersConfig::default(); - config.providers.insert( - "venice".into(), - moltis_config::schema::ProviderEntry { + config + .providers + .insert("venice".into(), moltis_config::schema::ProviderEntry { api_key: Some(secrecy::Secret::new("sk-test-venice".into())), ..Default::default() - }, - ); + }); let reg = ProviderRegistry::from_env_with_config(&config); assert!(!reg.list_models().iter().any(|m| m.provider == "venice")); @@ -1700,14 +1692,13 @@ mod tests { #[test] fn disabled_provider_not_registered() { let mut config = ProvidersConfig::default(); - config.providers.insert( - "mistral".into(), - moltis_config::schema::ProviderEntry { + config + .providers + .insert("mistral".into(), moltis_config::schema::ProviderEntry { api_key: Some(secrecy::Secret::new("sk-test".into())), enabled: false, ..Default::default() - }, - ); + }); let reg = ProviderRegistry::from_env_with_config(&config); assert!(!reg.list_models().iter().any(|m| m.provider == "mistral")); @@ -1727,14 +1718,13 @@ mod tests { #[test] fn custom_base_url_from_config() { let mut config = ProvidersConfig::default(); - config.providers.insert( - "mistral".into(), - moltis_config::schema::ProviderEntry { + config + .providers + .insert("mistral".into(), moltis_config::schema::ProviderEntry { api_key: Some(secrecy::Secret::new("sk-test".into())), base_url: Some("https://custom.mistral.example.com/v1".into()), ..Default::default() - }, - ); + }); let reg = ProviderRegistry::from_env_with_config(&config); assert!(reg.list_models().iter().any(|m| m.provider == "mistral")); @@ -1743,14 +1733,13 @@ mod tests { #[test] fn specific_model_override() { let mut config = ProvidersConfig::default(); - config.providers.insert( - "mistral".into(), - moltis_config::schema::ProviderEntry { + config + .providers + .insert("mistral".into(), moltis_config::schema::ProviderEntry { api_key: Some(secrecy::Secret::new("sk-test".into())), model: Some("mistral-small-latest".into()), ..Default::default() - }, - ); + }); let reg = ProviderRegistry::from_env_with_config(&config); let mistral_models: Vec<_> = reg @@ -1831,12 +1820,11 @@ mod tests { fn local_llm_requires_model_in_config() { // local-llm is a "bring your own model" provider — without a model it registers nothing. let mut config = ProvidersConfig::default(); - config.providers.insert( - "local".into(), - moltis_config::schema::ProviderEntry { + config + .providers + .insert("local".into(), moltis_config::schema::ProviderEntry { ..Default::default() - }, - ); + }); let reg = ProviderRegistry::from_env_with_config(&config); assert!(!reg.list_models().iter().any(|m| m.provider == "local-llm")); @@ -1846,13 +1834,12 @@ mod tests { #[test] fn local_llm_registers_with_model_in_config() { let mut config = ProvidersConfig::default(); - config.providers.insert( - "local".into(), - moltis_config::schema::ProviderEntry { + config + .providers + .insert("local".into(), moltis_config::schema::ProviderEntry { model: Some("qwen2.5-coder-7b-q4_k_m".into()), ..Default::default() - }, - ); + }); let reg = ProviderRegistry::from_env_with_config(&config); let local_models: Vec<_> = reg @@ -1868,14 +1855,13 @@ mod tests { #[test] fn local_llm_disabled_not_registered() { let mut config = ProvidersConfig::default(); - config.providers.insert( - "local".into(), - moltis_config::schema::ProviderEntry { + config + .providers + .insert("local".into(), moltis_config::schema::ProviderEntry { enabled: false, model: Some("qwen2.5-coder-7b-q4_k_m".into()), ..Default::default() - }, - ); + }); let reg = ProviderRegistry::from_env_with_config(&config); assert!(!reg.list_models().iter().any(|m| m.provider == "local-llm")); diff --git a/crates/agents/src/providers/openai_codex.rs b/crates/agents/src/providers/openai_codex.rs index 65444997..3b34f3f7 100644 --- a/crates/agents/src/providers/openai_codex.rs +++ b/crates/agents/src/providers/openai_codex.rs @@ -940,14 +940,11 @@ mod tests { #[test] fn convert_messages_tool_call_and_result() { let messages = vec![ - ChatMessage::assistant_with_tools( - None, - vec![ToolCall { - id: "call_1".to_string(), - name: "get_time".to_string(), - arguments: serde_json::json!({}), - }], - ), + ChatMessage::assistant_with_tools(None, vec![ToolCall { + id: "call_1".to_string(), + name: "get_time".to_string(), + arguments: serde_json::json!({}), + }]), ChatMessage::tool("call_1", "12:00"), ]; let converted = OpenAiCodexProvider::convert_messages(&messages); @@ -1072,14 +1069,11 @@ mod tests { .to_string(); let messages = vec![ ChatMessage::user("Take a screenshot"), - ChatMessage::assistant_with_tools( - None, - vec![ToolCall { - id: "call_screenshot".to_string(), - name: "browser_screenshot".to_string(), - arguments: serde_json::json!({}), - }], - ), + ChatMessage::assistant_with_tools(None, vec![ToolCall { + id: "call_screenshot".to_string(), + name: "browser_screenshot".to_string(), + arguments: serde_json::json!({}), + }]), ChatMessage::tool("call_screenshot", &tool_output), ChatMessage::assistant("Here is the screenshot."), ]; diff --git a/crates/agents/src/providers/openai_compat.rs b/crates/agents/src/providers/openai_compat.rs index e2b0b3e1..7788c3cf 100644 --- a/crates/agents/src/providers/openai_compat.rs +++ b/crates/agents/src/providers/openai_compat.rs @@ -608,10 +608,9 @@ mod tests { let events = finalize_stream(&state); assert_eq!(events.len(), 2); - assert!(matches!( - &events[0], - StreamEvent::ToolCallComplete { index: 0 } - )); + assert!(matches!(&events[0], StreamEvent::ToolCallComplete { + index: 0 + })); assert!(matches!( &events[1], StreamEvent::Done(usage) if usage.input_tokens == 10 && usage.output_tokens == 5 diff --git a/crates/agents/src/tool_registry.rs b/crates/agents/src/tool_registry.rs index bebe4985..89234c7d 100644 --- a/crates/agents/src/tool_registry.rs +++ b/crates/agents/src/tool_registry.rs @@ -52,25 +52,19 @@ impl ToolRegistry { /// Register a built-in tool. pub fn register(&mut self, tool: Box) { let name = tool.name().to_string(); - self.tools.insert( - name, - ToolEntry { - tool: Arc::from(tool), - source: ToolSource::Builtin, - }, - ); + self.tools.insert(name, ToolEntry { + tool: Arc::from(tool), + source: ToolSource::Builtin, + }); } /// Register a tool from an MCP server. pub fn register_mcp(&mut self, tool: Box, server: String) { let name = tool.name().to_string(); - self.tools.insert( - name, - ToolEntry { - tool: Arc::from(tool), - source: ToolSource::Mcp { server }, - }, - ); + self.tools.insert(name, ToolEntry { + tool: Arc::from(tool), + source: ToolSource::Mcp { server }, + }); } pub fn unregister(&mut self, name: &str) -> bool { @@ -124,13 +118,10 @@ impl ToolRegistry { .iter() .filter(|(name, _)| !name.starts_with(prefix)) .map(|(name, entry)| { - ( - name.clone(), - ToolEntry { - tool: Arc::clone(&entry.tool), - source: entry.source.clone(), - }, - ) + (name.clone(), ToolEntry { + tool: Arc::clone(&entry.tool), + source: entry.source.clone(), + }) }) .collect(); ToolRegistry { tools } @@ -143,13 +134,10 @@ impl ToolRegistry { .iter() .filter(|(_, entry)| !matches!(entry.source, ToolSource::Mcp { .. })) .map(|(name, entry)| { - ( - name.clone(), - ToolEntry { - tool: Arc::clone(&entry.tool), - source: entry.source.clone(), - }, - ) + (name.clone(), ToolEntry { + tool: Arc::clone(&entry.tool), + source: entry.source.clone(), + }) }) .collect(); ToolRegistry { tools } @@ -162,13 +150,10 @@ impl ToolRegistry { .iter() .filter(|(name, _)| !exclude.contains(&name.as_str())) .map(|(name, entry)| { - ( - name.clone(), - ToolEntry { - tool: Arc::clone(&entry.tool), - source: entry.source.clone(), - }, - ) + (name.clone(), ToolEntry { + tool: Arc::clone(&entry.tool), + source: entry.source.clone(), + }) }) .collect(); ToolRegistry { tools } @@ -184,13 +169,10 @@ impl ToolRegistry { .iter() .filter(|(name, _)| predicate(name)) .map(|(name, entry)| { - ( - name.clone(), - ToolEntry { - tool: Arc::clone(&entry.tool), - source: entry.source.clone(), - }, - ) + (name.clone(), ToolEntry { + tool: Arc::clone(&entry.tool), + source: entry.source.clone(), + }) }) .collect(); ToolRegistry { tools } diff --git a/crates/config/src/validate.rs b/crates/config/src/validate.rs index 2ff85f9b..c699c615 100644 --- a/crates/config/src/validate.rs +++ b/crates/config/src/validate.rs @@ -276,13 +276,10 @@ fn build_schema_map() -> KnownKeys { ("update_repository_url", Leaf), ])), ), - ( - "providers", - MapWithFields { - value: Box::new(provider_entry()), - fields: HashMap::from([("offered", Array(Box::new(Leaf)))]), - }, - ), + ("providers", MapWithFields { + value: Box::new(provider_entry()), + fields: HashMap::from([("offered", Array(Box::new(Leaf)))]), + }), ( "chat", Struct(HashMap::from([ diff --git a/crates/cron/src/service.rs b/crates/cron/src/service.rs index e21c3f50..5fc82385 100644 --- a/crates/cron/src/service.rs +++ b/crates/cron/src/service.rs @@ -801,13 +801,10 @@ mod tests { .unwrap(); let updated = svc - .update( - &job.id, - CronJobPatch { - name: Some("renamed".into()), - ..Default::default() - }, - ) + .update(&job.id, CronJobPatch { + name: Some("renamed".into()), + ..Default::default() + }) .await .unwrap(); diff --git a/crates/gateway/src/auth_webauthn.rs b/crates/gateway/src/auth_webauthn.rs index 2527b9e7..0f0daa8c 100644 --- a/crates/gateway/src/auth_webauthn.rs +++ b/crates/gateway/src/auth_webauthn.rs @@ -83,13 +83,11 @@ impl WebAuthnState { .map_err(|e| anyhow::anyhow!("start_passkey_registration: {e}"))?; let challenge_id = uuid::Uuid::new_v4().to_string(); - self.pending_registrations.insert( - challenge_id.clone(), - PendingRegistration { + self.pending_registrations + .insert(challenge_id.clone(), PendingRegistration { state: reg_state, created_at: Instant::now(), - }, - ); + }); Ok((challenge_id, ccr)) } @@ -134,13 +132,11 @@ impl WebAuthnState { .map_err(|e| anyhow::anyhow!("start_passkey_authentication: {e}"))?; let challenge_id = uuid::Uuid::new_v4().to_string(); - self.pending_authentications.insert( - challenge_id.clone(), - PendingAuthentication { + self.pending_authentications + .insert(challenge_id.clone(), PendingAuthentication { state: auth_state, created_at: Instant::now(), - }, - ); + }); Ok((challenge_id, rcr)) } diff --git a/crates/gateway/src/channel_events.rs b/crates/gateway/src/channel_events.rs index f1a7a51a..63cda37f 100644 --- a/crates/gateway/src/channel_events.rs +++ b/crates/gateway/src/channel_events.rs @@ -72,15 +72,10 @@ impl ChannelEventSink for GatewayChannelEventSink { return; }, }; - broadcast( - state, - "channel", - payload, - BroadcastOpts { - drop_if_slow: true, - ..Default::default() - }, - ) + broadcast(state, "channel", payload, BroadcastOpts { + drop_if_slow: true, + ..Default::default() + }) .await; } } @@ -113,15 +108,10 @@ impl ChannelEventSink for GatewayChannelEventSink { "sessionKey": &session_key, "messageIndex": msg_index, }); - broadcast( - state, - "chat", - payload, - BroadcastOpts { - drop_if_slow: true, - ..Default::default() - }, - ) + broadcast(state, "chat", payload, BroadcastOpts { + drop_if_slow: true, + ..Default::default() + }) .await; // Register the reply target so the chat "final" broadcast can @@ -349,15 +339,10 @@ impl ChannelEventSink for GatewayChannelEventSink { return; }, }; - broadcast( - state, - "channel", - payload, - BroadcastOpts { - drop_if_slow: true, - ..Default::default() - }, - ) + broadcast(state, "channel", payload, BroadcastOpts { + drop_if_slow: true, + ..Default::default() + }) .await; } else { warn!("request_disable_account: gateway not ready"); @@ -559,15 +544,10 @@ impl ChannelEventSink for GatewayChannelEventSink { "messageIndex": msg_index, "hasAttachments": true, }); - broadcast( - state, - "chat", - payload, - BroadcastOpts { - drop_if_slow: true, - ..Default::default() - }, - ) + broadcast(state, "chat", payload, BroadcastOpts { + drop_if_slow: true, + ..Default::default() + }) .await; // Register the reply target diff --git a/crates/gateway/src/methods.rs b/crates/gateway/src/methods.rs index c52312d8..106addcc 100644 --- a/crates/gateway/src/methods.rs +++ b/crates/gateway/src/methods.rs @@ -5520,10 +5520,10 @@ mod tests { VoiceProviderId::parse_tts_list_id, ); let ids: Vec<_> = filtered.into_iter().map(|p| p.id).collect(); - assert_eq!( - ids, - vec![VoiceProviderId::OpenaiTts, VoiceProviderId::Piper] - ); + assert_eq!(ids, vec![ + VoiceProviderId::OpenaiTts, + VoiceProviderId::Piper + ]); } #[test] diff --git a/crates/gateway/src/provider_setup.rs b/crates/gateway/src/provider_setup.rs index f6ea556d..b9941b95 100644 --- a/crates/gateway/src/provider_setup.rs +++ b/crates/gateway/src/provider_setup.rs @@ -86,14 +86,11 @@ impl KeyStore { return old_format .into_iter() .map(|(k, v)| { - ( - k, - ProviderConfig { - api_key: Some(v), - base_url: None, - model: None, - }, - ) + (k, ProviderConfig { + api_key: Some(v), + base_url: None, + model: None, + }) }) .collect(); } @@ -1873,13 +1870,12 @@ mod tests { .expect("openai-codex should exist"); let mut config = ProvidersConfig::default(); - config.providers.insert( - "openai-codex".into(), - ProviderEntry { + config + .providers + .insert("openai-codex".into(), ProviderEntry { enabled: false, ..Default::default() - }, - ); + }); assert!(!svc.is_provider_configured(&provider, &config)); } @@ -1906,13 +1902,10 @@ mod tests { store.save("anthropic", "sk-saved").unwrap(); let mut base = ProvidersConfig::default(); - base.providers.insert( - "anthropic".into(), - ProviderEntry { - api_key: Some(Secret::new("sk-config".into())), - ..Default::default() - }, - ); + base.providers.insert("anthropic".into(), ProviderEntry { + api_key: Some(Secret::new("sk-config".into())), + ..Default::default() + }); let merged = config_with_saved_keys(&base, &store); let entry = merged.get("anthropic").unwrap(); // Config key takes precedence over saved key. @@ -2111,14 +2104,11 @@ mod tests { Some(&home) )); - home.save( - "github-copilot", - &OAuthTokens { - access_token: Secret::new("home-token".to_string()), - refresh_token: None, - expires_at: None, - }, - ) + home.save("github-copilot", &OAuthTokens { + access_token: Secret::new("home-token".to_string()), + refresh_token: None, + expires_at: None, + }) .expect("save home token"); assert!(has_oauth_tokens_for_provider( @@ -2311,23 +2301,17 @@ mod tests { let mut empty = ProvidersConfig::default(); assert!(!has_explicit_provider_settings(&empty)); - empty.providers.insert( - "openai".into(), - ProviderEntry { - api_key: Some(Secret::new("sk-test".into())), - ..Default::default() - }, - ); + empty.providers.insert("openai".into(), ProviderEntry { + api_key: Some(Secret::new("sk-test".into())), + ..Default::default() + }); assert!(has_explicit_provider_settings(&empty)); let mut model_only = ProvidersConfig::default(); - model_only.providers.insert( - "ollama".into(), - ProviderEntry { - model: Some("llama3".into()), - ..Default::default() - }, - ); + model_only.providers.insert("ollama".into(), ProviderEntry { + model: Some("llama3".into()), + ..Default::default() + }); assert!(has_explicit_provider_settings(&model_only)); } diff --git a/crates/gateway/src/request_throttle.rs b/crates/gateway/src/request_throttle.rs index e42d10d4..eb7f422d 100644 --- a/crates/gateway/src/request_throttle.rs +++ b/crates/gateway/src/request_throttle.rs @@ -7,13 +7,15 @@ use std::{ time::{Duration, Instant}, }; -use axum::{ - extract::{ConnectInfo, State}, - http::{HeaderMap, Method, StatusCode}, - middleware::Next, - response::{IntoResponse, Json, Response}, +use { + axum::{ + extract::{ConnectInfo, State}, + http::{HeaderMap, Method, StatusCode}, + middleware::Next, + response::{IntoResponse, Json, Response}, + }, + dashmap::{DashMap, mapref::entry::Entry}, }; -use dashmap::{DashMap, mapref::entry::Entry}; use crate::server::AppState; diff --git a/crates/gateway/src/server.rs b/crates/gateway/src/server.rs index 7f3c42a3..2db8a466 100644 --- a/crates/gateway/src/server.rs +++ b/crates/gateway/src/server.rs @@ -126,14 +126,11 @@ impl moltis_tools::location::LocationRequester for GatewayLocationRequester { { let mut inner_w = self.state.inner.write().await; let invokes = &mut inner_w.pending_invokes; - invokes.insert( - request_id.clone(), - crate::state::PendingInvoke { - request_id: request_id.clone(), - sender: tx, - created_at: std::time::Instant::now(), - }, - ); + invokes.insert(request_id.clone(), crate::state::PendingInvoke { + request_id: request_id.clone(), + sender: tx, + created_at: std::time::Instant::now(), + }); } // Wait up to 30 seconds for the user to grant/deny permission. @@ -247,14 +244,13 @@ impl moltis_tools::location::LocationRequester for GatewayLocationRequester { let (tx, rx) = tokio::sync::oneshot::channel(); { let mut inner = self.state.inner.write().await; - inner.pending_invokes.insert( - pending_key.clone(), - crate::state::PendingInvoke { + inner + .pending_invokes + .insert(pending_key.clone(), crate::state::PendingInvoke { request_id: pending_key.clone(), sender: tx, created_at: std::time::Instant::now(), - }, - ); + }); } // Wait up to 60 seconds — user needs to navigate Telegram's UI. @@ -976,17 +972,16 @@ pub async fn start_gateway( "sse" => moltis_mcp::registry::TransportType::Sse, _ => moltis_mcp::registry::TransportType::Stdio, }; - merged.servers.insert( - name.clone(), - moltis_mcp::McpServerConfig { + merged + .servers + .insert(name.clone(), moltis_mcp::McpServerConfig { command: entry.command.clone(), args: entry.args.clone(), env: entry.env.clone(), enabled: entry.enabled, transport, url: entry.url.clone(), - }, - ); + }); } } mcp_configured_count = merged.servers.values().filter(|s| s.enabled).count(); @@ -2840,15 +2835,10 @@ pub async fn start_gateway( } }; if changed && let Ok(payload) = serde_json::to_value(&next) { - broadcast( - &update_state, - "update.available", - payload, - BroadcastOpts { - drop_if_slow: true, - ..Default::default() - }, - ) + broadcast(&update_state, "update.available", payload, BroadcastOpts { + drop_if_slow: true, + ..Default::default() + }) .await; } }, @@ -2911,15 +2901,12 @@ pub async fn start_gateway( .by_provider .iter() .map(|(name, metrics)| { - ( - name.clone(), - moltis_metrics::ProviderTokens { - input_tokens: metrics.input_tokens, - output_tokens: metrics.output_tokens, - completions: metrics.completions, - errors: metrics.errors, - }, - ) + (name.clone(), moltis_metrics::ProviderTokens { + input_tokens: metrics.input_tokens, + output_tokens: metrics.output_tokens, + completions: metrics.completions, + errors: metrics.errors, + }) }) .collect(); diff --git a/crates/gateway/src/state.rs b/crates/gateway/src/state.rs index 7460fc6d..a83991e0 100644 --- a/crates/gateway/src/state.rs +++ b/crates/gateway/src/state.rs @@ -183,12 +183,9 @@ impl DedupeCache { { self.entries.remove(&oldest_key); } - self.entries.insert( - key.to_string(), - DedupeEntry { - inserted_at: Instant::now(), - }, - ); + self.entries.insert(key.to_string(), DedupeEntry { + inserted_at: Instant::now(), + }); false } diff --git a/crates/mcp/src/manager.rs b/crates/mcp/src/manager.rs index 8965a9df..8c87fffb 100644 --- a/crates/mcp/src/manager.rs +++ b/crates/mcp/src/manager.rs @@ -318,13 +318,10 @@ mod tests { #[tokio::test] async fn test_status_shows_stopped_for_configured_but_not_started() { let mut reg = McpRegistry::new(); - reg.servers.insert( - "test".into(), - McpServerConfig { - command: "echo".into(), - ..Default::default() - }, - ); + reg.servers.insert("test".into(), McpServerConfig { + command: "echo".into(), + ..Default::default() + }); let mgr = McpManager::new(reg); let statuses = mgr.status_all().await; diff --git a/crates/mcp/src/registry.rs b/crates/mcp/src/registry.rs index b309e7b1..5e0e5f83 100644 --- a/crates/mcp/src/registry.rs +++ b/crates/mcp/src/registry.rs @@ -167,13 +167,10 @@ mod tests { #[test] fn test_registry_add_remove() { let mut reg = McpRegistry::new(); - reg.servers.insert( - "test".into(), - McpServerConfig { - command: "echo".into(), - ..Default::default() - }, - ); + reg.servers.insert("test".into(), McpServerConfig { + command: "echo".into(), + ..Default::default() + }); assert_eq!(reg.list().len(), 1); assert!(reg.get("test").is_some()); @@ -184,13 +181,10 @@ mod tests { #[test] fn test_registry_enable_disable() { let mut reg = McpRegistry::new(); - reg.servers.insert( - "srv".into(), - McpServerConfig { - command: "test".into(), - ..Default::default() - }, - ); + reg.servers.insert("srv".into(), McpServerConfig { + command: "test".into(), + ..Default::default() + }); assert_eq!(reg.enabled_servers().len(), 1); @@ -201,14 +195,11 @@ mod tests { #[test] fn test_registry_serialization() { let mut reg = McpRegistry::new(); - reg.servers.insert( - "fs".into(), - McpServerConfig { - command: "mcp-server-filesystem".into(), - args: vec!["/tmp".into()], - ..Default::default() - }, - ); + reg.servers.insert("fs".into(), McpServerConfig { + command: "mcp-server-filesystem".into(), + args: vec!["/tmp".into()], + ..Default::default() + }); let json = serde_json::to_string(®).unwrap(); let parsed: McpRegistry = serde_json::from_str(&json).unwrap(); @@ -229,15 +220,12 @@ mod tests { let path = dir.path().join("mcp.json"); let mut reg = McpRegistry::load(&path).unwrap(); - reg.servers.insert( - "test".into(), - McpServerConfig { - command: "echo".into(), - args: vec!["hello".into()], - env: HashMap::from([("FOO".into(), "bar".into())]), - ..Default::default() - }, - ); + reg.servers.insert("test".into(), McpServerConfig { + command: "echo".into(), + args: vec!["hello".into()], + env: HashMap::from([("FOO".into(), "bar".into())]), + ..Default::default() + }); reg.save().unwrap(); let loaded = McpRegistry::load(&path).unwrap(); diff --git a/crates/metrics/src/store.rs b/crates/metrics/src/store.rs index 95b457f9..d783e9d6 100644 --- a/crates/metrics/src/store.rs +++ b/crates/metrics/src/store.rs @@ -343,24 +343,22 @@ mod tests { let store = SqliteMetricsStore::in_memory().await.unwrap(); let mut point = make_point(1000, 10); - point.by_provider.insert( - "anthropic".to_string(), - ProviderTokens { + point + .by_provider + .insert("anthropic".to_string(), ProviderTokens { input_tokens: 500, output_tokens: 200, completions: 5, errors: 0, - }, - ); - point.by_provider.insert( - "openai".to_string(), - ProviderTokens { + }); + point + .by_provider + .insert("openai".to_string(), ProviderTokens { input_tokens: 300, output_tokens: 100, completions: 5, errors: 1, - }, - ); + }); store.save_point(&point).await.unwrap(); diff --git a/crates/oauth/src/defaults.rs b/crates/oauth/src/defaults.rs index c08e30dc..e1ead945 100644 --- a/crates/oauth/src/defaults.rs +++ b/crates/oauth/src/defaults.rs @@ -8,50 +8,41 @@ fn builtin_defaults() -> HashMap { // GitHub Copilot uses device flow (handled by the provider itself), // but we store a config entry so `load_oauth_config` returns Some // and the gateway recognises it as an OAuth provider. - m.insert( - "github-copilot".into(), - OAuthConfig { - client_id: "Iv1.b507a08c87ecfe98".into(), - auth_url: "https://github.com/login/device/code".into(), - token_url: "https://github.com/login/oauth/access_token".into(), - redirect_uri: String::new(), - scopes: vec![], - extra_auth_params: vec![], - device_flow: true, - }, - ); - m.insert( - "kimi-code".into(), - OAuthConfig { - client_id: "17e5f671-d194-4dfb-9706-5516cb48c098".into(), - auth_url: "https://auth.kimi.com/api/oauth/device_authorization".into(), - token_url: "https://auth.kimi.com/api/oauth/token".into(), - redirect_uri: String::new(), - scopes: vec![], - extra_auth_params: vec![], - device_flow: true, - }, - ); - m.insert( - "openai-codex".into(), - OAuthConfig { - client_id: "app_EMoamEEZ73f0CkXaXp7hrann".into(), - auth_url: "https://auth.openai.com/oauth/authorize".into(), - token_url: "https://auth.openai.com/oauth/token".into(), - redirect_uri: "http://localhost:1455/auth/callback".into(), - scopes: vec![ - "openid".into(), - "profile".into(), - "email".into(), - "offline_access".into(), - ], - extra_auth_params: vec![ - ("id_token_add_organizations".into(), "true".into()), - ("codex_cli_simplified_flow".into(), "true".into()), - ], - device_flow: false, - }, - ); + m.insert("github-copilot".into(), OAuthConfig { + client_id: "Iv1.b507a08c87ecfe98".into(), + auth_url: "https://github.com/login/device/code".into(), + token_url: "https://github.com/login/oauth/access_token".into(), + redirect_uri: String::new(), + scopes: vec![], + extra_auth_params: vec![], + device_flow: true, + }); + m.insert("kimi-code".into(), OAuthConfig { + client_id: "17e5f671-d194-4dfb-9706-5516cb48c098".into(), + auth_url: "https://auth.kimi.com/api/oauth/device_authorization".into(), + token_url: "https://auth.kimi.com/api/oauth/token".into(), + redirect_uri: String::new(), + scopes: vec![], + extra_auth_params: vec![], + device_flow: true, + }); + m.insert("openai-codex".into(), OAuthConfig { + client_id: "app_EMoamEEZ73f0CkXaXp7hrann".into(), + auth_url: "https://auth.openai.com/oauth/authorize".into(), + token_url: "https://auth.openai.com/oauth/token".into(), + redirect_uri: "http://localhost:1455/auth/callback".into(), + scopes: vec![ + "openid".into(), + "profile".into(), + "email".into(), + "offline_access".into(), + ], + extra_auth_params: vec![ + ("id_token_add_organizations".into(), "true".into()), + ("codex_cli_simplified_flow".into(), "true".into()), + ], + device_flow: false, + }); m } diff --git a/crates/telegram/src/handlers.rs b/crates/telegram/src/handlers.rs index 4f7cb808..efd43281 100644 --- a/crates/telegram/src/handlers.rs +++ b/crates/telegram/src/handlers.rs @@ -1746,23 +1746,20 @@ mod tests { { let mut map = accounts.write().expect("accounts write lock"); - map.insert( - account_id.to_string(), - AccountState { - bot: bot.clone(), - bot_username: Some("test_bot".into()), - account_id: account_id.to_string(), - config: TelegramAccountConfig { - token: Secret::new("test-token".to_string()), - ..Default::default() - }, - outbound: Arc::clone(&outbound), - cancel: CancellationToken::new(), - message_log: None, - event_sink: Some(Arc::clone(&sink) as Arc), - otp: std::sync::Mutex::new(OtpState::new(300)), + map.insert(account_id.to_string(), AccountState { + bot: bot.clone(), + bot_username: Some("test_bot".into()), + account_id: account_id.to_string(), + config: TelegramAccountConfig { + token: Secret::new("test-token".to_string()), + ..Default::default() }, - ); + outbound: Arc::clone(&outbound), + cancel: CancellationToken::new(), + message_log: None, + event_sink: Some(Arc::clone(&sink) as Arc), + otp: std::sync::Mutex::new(OtpState::new(300)), + }); } let msg: Message = serde_json::from_value(json!({ diff --git a/crates/telegram/src/otp.rs b/crates/telegram/src/otp.rs index 4d1ebee4..bdec9f24 100644 --- a/crates/telegram/src/otp.rs +++ b/crates/telegram/src/otp.rs @@ -158,12 +158,9 @@ impl OtpState { challenge.attempts += 1; if challenge.attempts >= MAX_ATTEMPTS { self.challenges.remove(peer_id); - self.lockouts.insert( - peer_id.to_string(), - Lockout { - until: now + self.cooldown, - }, - ); + self.lockouts.insert(peer_id.to_string(), Lockout { + until: now + self.cooldown, + }); return OtpVerifyResult::LockedOut; } diff --git a/crates/tools/src/sandbox.rs b/crates/tools/src/sandbox.rs index 150f9bc7..481b21d7 100644 --- a/crates/tools/src/sandbox.rs +++ b/crates/tools/src/sandbox.rs @@ -1725,10 +1725,14 @@ mod tests { }; let docker = DockerSandbox::new(config); let args = docker.resource_args(); - assert_eq!( - args, - vec!["--memory", "256M", "--cpus", "0.5", "--pids-limit", "50"] - ); + assert_eq!(args, vec![ + "--memory", + "256M", + "--cpus", + "0.5", + "--pids-limit", + "50" + ]); } #[test] diff --git a/crates/tools/src/sandbox_packages.rs b/crates/tools/src/sandbox_packages.rs index ea8bbaed..c805facf 100644 --- a/crates/tools/src/sandbox_packages.rs +++ b/crates/tools/src/sandbox_packages.rs @@ -35,156 +35,123 @@ use crate::{exec::ExecOpts, sandbox::SandboxRouter}; /// in "Other". Library/dev/font packages are filtered out before /// categorization (see [`is_infrastructure_package`]). const CATEGORY_MAP: &[(&str, &[&str])] = &[ - ( - "Networking", - &[ - "curl", - "wget", - "ca-certificates", - "dnsutils", - "netcat-openbsd", - "openssh-client", - "iproute2", - "net-tools", - ], - ), - ( - "Languages", - &[ - "python3", - "python3-pip", - "python3-venv", - "python-is-python3", - "nodejs", - "npm", - "ruby", - ], - ), - ( - "Build tools", - &[ - "build-essential", - "clang", - "pkg-config", - "autoconf", - "automake", - "libtool", - "bison", - "flex", - "dpkg-dev", - "fakeroot", - ], - ), - ( - "Compression", - &[ - "zip", - "unzip", - "bzip2", - "xz-utils", - "p7zip-full", - "tar", - "zstd", - "lz4", - "pigz", - ], - ), - ( - "CLI utilities", - &[ - "git", - "gnupg2", - "jq", - "rsync", - "file", - "tree", - "sqlite3", - "sudo", - "locales", - "tzdata", - "shellcheck", - "patchelf", - "tmux", - ], - ), + ("Networking", &[ + "curl", + "wget", + "ca-certificates", + "dnsutils", + "netcat-openbsd", + "openssh-client", + "iproute2", + "net-tools", + ]), + ("Languages", &[ + "python3", + "python3-pip", + "python3-venv", + "python-is-python3", + "nodejs", + "npm", + "ruby", + ]), + ("Build tools", &[ + "build-essential", + "clang", + "pkg-config", + "autoconf", + "automake", + "libtool", + "bison", + "flex", + "dpkg-dev", + "fakeroot", + ]), + ("Compression", &[ + "zip", + "unzip", + "bzip2", + "xz-utils", + "p7zip-full", + "tar", + "zstd", + "lz4", + "pigz", + ]), + ("CLI utilities", &[ + "git", + "gnupg2", + "jq", + "rsync", + "file", + "tree", + "sqlite3", + "sudo", + "locales", + "tzdata", + "shellcheck", + "patchelf", + "tmux", + ]), ("Text processing", &["ripgrep", "fd-find", "yq"]), ("Browser automation", &["chromium"]), - ( - "Image processing", - &[ - "imagemagick", - "graphicsmagick", - "libvips-tools", - "pngquant", - "optipng", - "jpegoptim", - "webp", - "libimage-exiftool-perl", - ], - ), - ( - "Audio/video", - &[ - "ffmpeg", - "sox", - "lame", - "flac", - "vorbis-tools", - "opus-tools", - "mediainfo", - ], - ), - ( - "Documents", - &[ - "pandoc", - "poppler-utils", - "ghostscript", - "texlive-latex-base", - "texlive-latex-extra", - "texlive-fonts-recommended", - "antiword", - "catdoc", - "unrtf", - "libreoffice-core", - "libreoffice-writer", - ], - ), - ( - "Data processing", - &[ - "csvtool", - "xmlstarlet", - "html2text", - "dos2unix", - "miller", - "datamash", - ], - ), - ( - "GIS/maps", - &[ - "gdal-bin", - "mapnik-utils", - "osm2pgsql", - "osmium-tool", - "osmctools", - "python3-mapnik", - ], - ), + ("Image processing", &[ + "imagemagick", + "graphicsmagick", + "libvips-tools", + "pngquant", + "optipng", + "jpegoptim", + "webp", + "libimage-exiftool-perl", + ]), + ("Audio/video", &[ + "ffmpeg", + "sox", + "lame", + "flac", + "vorbis-tools", + "opus-tools", + "mediainfo", + ]), + ("Documents", &[ + "pandoc", + "poppler-utils", + "ghostscript", + "texlive-latex-base", + "texlive-latex-extra", + "texlive-fonts-recommended", + "antiword", + "catdoc", + "unrtf", + "libreoffice-core", + "libreoffice-writer", + ]), + ("Data processing", &[ + "csvtool", + "xmlstarlet", + "html2text", + "dos2unix", + "miller", + "datamash", + ]), + ("GIS/maps", &[ + "gdal-bin", + "mapnik-utils", + "osm2pgsql", + "osmium-tool", + "osmctools", + "python3-mapnik", + ]), ("CalDAV/CardDAV", &["vdirsyncer", "khal", "python3-caldav"]), - ( - "Email", - &[ - "isync", - "offlineimap3", - "notmuch", - "notmuch-mutt", - "aerc", - "mutt", - "neomutt", - ], - ), + ("Email", &[ + "isync", + "offlineimap3", + "notmuch", + "notmuch-mutt", + "aerc", + "mutt", + "neomutt", + ]), ("Newsgroups (NNTP)", &["tin", "slrn"]), ("Messaging APIs", &["python3-discord"]), ]; diff --git a/crates/tools/src/web_fetch.rs b/crates/tools/src/web_fetch.rs index 7e383b32..c12e704e 100644 --- a/crates/tools/src/web_fetch.rs +++ b/crates/tools/src/web_fetch.rs @@ -65,13 +65,10 @@ impl WebFetchTool { let now = Instant::now(); cache.retain(|_, e| e.expires_at > now); } - cache.insert( - key, - CacheEntry { - value, - expires_at: Instant::now() + self.cache_ttl, - }, - ); + cache.insert(key, CacheEntry { + value, + expires_at: Instant::now() + self.cache_ttl, + }); } } diff --git a/crates/tools/src/web_search.rs b/crates/tools/src/web_search.rs index d5404fdb..d1112725 100644 --- a/crates/tools/src/web_search.rs +++ b/crates/tools/src/web_search.rs @@ -181,13 +181,10 @@ impl WebSearchTool { let now = Instant::now(); cache.retain(|_, e| e.expires_at > now); } - cache.insert( - key, - CacheEntry { - value, - expires_at: Instant::now() + self.cache_ttl, - }, - ); + cache.insert(key, CacheEntry { + value, + expires_at: Instant::now() + self.cache_ttl, + }); } } From f507e3af6d5d61a65006857eace93b352e557821 Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Tue, 10 Feb 2026 18:25:07 -0800 Subject: [PATCH 05/16] fix(validate): use nightly clippy on macOS fallback --- scripts/local-validate.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/local-validate.sh b/scripts/local-validate.sh index 426a70e6..3a9fe3c9 100755 --- a/scripts/local-validate.sh +++ b/scripts/local-validate.sh @@ -148,7 +148,7 @@ coverage_cmd="${LOCAL_VALIDATE_COVERAGE_CMD:-cargo llvm-cov --workspace --all-fe if [[ "$(uname -s)" == "Darwin" ]] && ! command -v nvcc >/dev/null 2>&1; then if [[ -z "${LOCAL_VALIDATE_LINT_CMD:-}" ]]; then - lint_cmd="cargo clippy --workspace -- -D warnings" + lint_cmd="cargo +nightly clippy --workspace -- -D warnings" fi if [[ -z "${LOCAL_VALIDATE_TEST_CMD:-}" ]]; then test_cmd="cargo test" From e13e1ae11c72d89348615b90d9c8c4abc3b7e7d6 Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Tue, 10 Feb 2026 18:27:35 -0800 Subject: [PATCH 06/16] fix(validate): use nightly test/coverage on macOS fallback --- scripts/local-validate.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/local-validate.sh b/scripts/local-validate.sh index 3a9fe3c9..92d91e5d 100755 --- a/scripts/local-validate.sh +++ b/scripts/local-validate.sh @@ -151,10 +151,10 @@ if [[ "$(uname -s)" == "Darwin" ]] && ! command -v nvcc >/dev/null 2>&1; then lint_cmd="cargo +nightly clippy --workspace -- -D warnings" fi if [[ -z "${LOCAL_VALIDATE_TEST_CMD:-}" ]]; then - test_cmd="cargo test" + test_cmd="cargo +nightly test" fi if [[ -z "${LOCAL_VALIDATE_COVERAGE_CMD:-}" ]]; then - coverage_cmd="cargo llvm-cov --workspace --html" + coverage_cmd="cargo +nightly llvm-cov --workspace --html" fi echo "Detected macOS without nvcc; using non-CUDA local validation commands." >&2 echo "Override with LOCAL_VALIDATE_LINT_CMD / LOCAL_VALIDATE_TEST_CMD if needed." >&2 From 787a0575161b6beb32910818ab58c6209a5962d1 Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Tue, 10 Feb 2026 18:30:54 -0800 Subject: [PATCH 07/16] fix(validate): reinstall UI deps when playwright bin is missing --- scripts/local-validate.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/local-validate.sh b/scripts/local-validate.sh index 92d91e5d..675d21b3 100755 --- a/scripts/local-validate.sh +++ b/scripts/local-validate.sh @@ -143,7 +143,7 @@ biome_cmd="${LOCAL_VALIDATE_BIOME_CMD:-biome ci --diagnostic-level=error crates/ zizmor_cmd="${LOCAL_VALIDATE_ZIZMOR_CMD:-zizmor . --min-severity high >/dev/null 2>&1 || true}" lint_cmd="${LOCAL_VALIDATE_LINT_CMD:-cargo clippy --workspace --all-features -- -D warnings}" test_cmd="${LOCAL_VALIDATE_TEST_CMD:-cargo test --all-features}" -e2e_cmd="${LOCAL_VALIDATE_E2E_CMD:-cd crates/gateway/ui && if [ ! -d node_modules ]; then npm ci; fi && npm run e2e:install && npm run e2e}" +e2e_cmd="${LOCAL_VALIDATE_E2E_CMD:-cd crates/gateway/ui && if [ ! -x node_modules/.bin/playwright ]; then npm ci; fi && npm run e2e:install && npm run e2e}" coverage_cmd="${LOCAL_VALIDATE_COVERAGE_CMD:-cargo llvm-cov --workspace --all-features --html}" if [[ "$(uname -s)" == "Darwin" ]] && ! command -v nvcc >/dev/null 2>&1; then From 828fc782b2bc231c10e1ebe59daf8219927aa94f Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Tue, 10 Feb 2026 18:39:44 -0800 Subject: [PATCH 08/16] fix(validate): run e2e servers with nightly and force fresh playwright webservers --- crates/gateway/ui/e2e/start-gateway-onboarding.sh | 2 +- crates/gateway/ui/e2e/start-gateway.sh | 2 +- scripts/local-validate.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/gateway/ui/e2e/start-gateway-onboarding.sh b/crates/gateway/ui/e2e/start-gateway-onboarding.sh index a94239d7..8988f0e4 100755 --- a/crates/gateway/ui/e2e/start-gateway-onboarding.sh +++ b/crates/gateway/ui/e2e/start-gateway-onboarding.sh @@ -37,5 +37,5 @@ fi if [ -n "${BINARY}" ]; then exec "${BINARY}" --no-tls --bind 127.0.0.1 --port "${PORT}" else - exec cargo run --bin moltis -- --no-tls --bind 127.0.0.1 --port "${PORT}" + exec cargo +nightly run --bin moltis -- --no-tls --bind 127.0.0.1 --port "${PORT}" fi diff --git a/crates/gateway/ui/e2e/start-gateway.sh b/crates/gateway/ui/e2e/start-gateway.sh index eb7ce950..b7bc9723 100755 --- a/crates/gateway/ui/e2e/start-gateway.sh +++ b/crates/gateway/ui/e2e/start-gateway.sh @@ -52,5 +52,5 @@ fi if [ -n "${BINARY}" ]; then exec "${BINARY}" --no-tls --bind 127.0.0.1 --port "${PORT}" else - exec cargo run --bin moltis -- --no-tls --bind 127.0.0.1 --port "${PORT}" + exec cargo +nightly run --bin moltis -- --no-tls --bind 127.0.0.1 --port "${PORT}" fi diff --git a/scripts/local-validate.sh b/scripts/local-validate.sh index 675d21b3..afacf3f5 100755 --- a/scripts/local-validate.sh +++ b/scripts/local-validate.sh @@ -143,7 +143,7 @@ biome_cmd="${LOCAL_VALIDATE_BIOME_CMD:-biome ci --diagnostic-level=error crates/ zizmor_cmd="${LOCAL_VALIDATE_ZIZMOR_CMD:-zizmor . --min-severity high >/dev/null 2>&1 || true}" lint_cmd="${LOCAL_VALIDATE_LINT_CMD:-cargo clippy --workspace --all-features -- -D warnings}" test_cmd="${LOCAL_VALIDATE_TEST_CMD:-cargo test --all-features}" -e2e_cmd="${LOCAL_VALIDATE_E2E_CMD:-cd crates/gateway/ui && if [ ! -x node_modules/.bin/playwright ]; then npm ci; fi && npm run e2e:install && npm run e2e}" +e2e_cmd="${LOCAL_VALIDATE_E2E_CMD:-cd crates/gateway/ui && if [ ! -x node_modules/.bin/playwright ]; then npm ci; fi && npm run e2e:install && CI=1 npm run e2e}" coverage_cmd="${LOCAL_VALIDATE_COVERAGE_CMD:-cargo llvm-cov --workspace --all-features --html}" if [[ "$(uname -s)" == "Darwin" ]] && ! command -v nvcc >/dev/null 2>&1; then From 801da6b8b8bc61d9581fe5e2af18724d81a65d90 Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Tue, 10 Feb 2026 18:42:42 -0800 Subject: [PATCH 09/16] fix(validate): clean stale e2e webserver ports before playwright --- scripts/local-validate.sh | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/scripts/local-validate.sh b/scripts/local-validate.sh index afacf3f5..4ff1c2c8 100755 --- a/scripts/local-validate.sh +++ b/scripts/local-validate.sh @@ -202,6 +202,36 @@ repair_stale_llama_build_dirs() { shopt -u nullglob } +cleanup_e2e_ports() { + if ! command -v lsof >/dev/null 2>&1; then + return 0 + fi + + local port + for port in "${MOLTIS_E2E_PORT:-18789}" "${MOLTIS_E2E_ONBOARDING_PORT:-18790}"; do + local pids + pids="$(lsof -ti "tcp:${port}" -sTCP:LISTEN 2>/dev/null || true)" + if [[ -z "$pids" ]]; then + continue + fi + + echo "Stopping stale process(es) on TCP ${port}: ${pids//$'\n'/ }" + while IFS= read -r pid; do + [[ -n "$pid" ]] && kill -TERM "$pid" 2>/dev/null || true + done <<<"$pids" + + sleep 1 + + local remaining + remaining="$(lsof -ti "tcp:${port}" -sTCP:LISTEN 2>/dev/null || true)" + if [[ -n "$remaining" ]]; then + while IFS= read -r pid; do + [[ -n "$pid" ]] && kill -KILL "$pid" 2>/dev/null || true + done <<<"$remaining" + fi + done +} + set_status() { local state="$1" local context="$2" @@ -369,6 +399,7 @@ run_check "local/lockfile" "cargo fetch --locked" # These do not wait on local/zizmor (advisory and non-blocking). run_check "local/lint" "$lint_cmd" run_check "local/test" "$test_cmd" +cleanup_e2e_ports run_check "local/e2e" "$e2e_cmd" # Coverage (optional — requires cargo-llvm-cov). From 9b32d893e4751d13cc9080501c7b32bbd4aadf13 Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Tue, 10 Feb 2026 18:44:08 -0800 Subject: [PATCH 10/16] fix(validate): continue checks when github status publish is rate-limited --- scripts/local-validate.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/local-validate.sh b/scripts/local-validate.sh index 4ff1c2c8..b8f6796d 100755 --- a/scripts/local-validate.sh +++ b/scripts/local-validate.sh @@ -5,6 +5,7 @@ set -euo pipefail ACTIVE_PIDS=() CURRENT_PID="" RUN_CHECK_ASYNC_PID="" +STATUS_PUBLISH_ENABLED=1 remove_active_pid() { local target="$1" @@ -241,6 +242,10 @@ set_status() { return 0 fi + if [[ "$STATUS_PUBLISH_ENABLED" -eq 0 ]]; then + return 0 + fi + if ! gh api "repos/$REPO/statuses/$SHA" \ -f state="$state" \ -f context="$context" \ @@ -258,7 +263,9 @@ If this is an org with SSO enforcement, authorize the token for the org. If GH_TOKEN is set in your shell, try unsetting it to use your gh auth token: unset GH_TOKEN EOF - return 1 + STATUS_PUBLISH_ENABLED=0 + echo "Disabling further status publication for this run; continuing local checks." >&2 + return 0 fi } From 48e5517e42f89091af8805d99212e66b7ea7ff07 Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Tue, 10 Feb 2026 18:51:03 -0800 Subject: [PATCH 11/16] chore: sync Cargo.lock --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 96b736a4..95ddebed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5121,7 +5121,7 @@ dependencies = [ [[package]] name = "moltis-whatsapp" -version = "0.6.1" +version = "0.7.1" dependencies = [ "anyhow", "async-trait", From a2fb182a8f4300bffd5aa0626ac47c45e63f13b0 Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Tue, 10 Feb 2026 18:59:46 -0800 Subject: [PATCH 12/16] docs: set repo command timeout guidance to 15 minutes --- CLAUDE.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index c14b8260..ea61ff2a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,11 @@ https://docs.openclaw.ai and its code is at https://github.com/openclaw/openclaw All code you write must have tests with high coverage. Always check for Security to make code safe. +### Repo Timeout Override + +- If a command runs longer than 15 minutes, stop it, capture logs/context, and + check with the user before retrying. + ## Cargo Features When adding a new feature behind a cargo feature flag, **always enable it by From 9022bd5857af48789e674599f39e043762eb4d4b Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Tue, 10 Feb 2026 19:18:42 -0800 Subject: [PATCH 13/16] docs: clarify PR-mode local validation workflow --- CLAUDE.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index ea61ff2a..2fb2682e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -942,7 +942,9 @@ integrate them together so nothing is lost. **Local validation:** When a PR exists, **always** run `./scripts/local-validate.sh ` (e.g. `./scripts/local-validate.sh 63`) to check fmt, lint, and tests locally and publish commit statuses to the PR. -Running the script without a PR number is useless — it skips status publishing. +Never run it without a PR number. +If the script auto-commits `Cargo.lock` (or any change), push first, then rerun +`./scripts/local-validate.sh ` so local `HEAD` matches the PR head. **PR description quality:** Every pull request must include a clear, reviewer-friendly description with at least these sections: From 73c7519b01969441ff4b53537961583de50be489 Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Wed, 11 Feb 2026 13:20:18 -0800 Subject: [PATCH 14/16] style: reformat with pinned nightly rustfmt after merge --- crates/agents/src/model.rs | 13 +- crates/agents/src/providers/github_copilot.rs | 13 +- crates/agents/src/providers/mod.rs | 126 ++++----- crates/agents/src/providers/openai_codex.rs | 26 +- crates/agents/src/providers/openai_compat.rs | 7 +- crates/agents/src/tool_registry.rs | 66 ++--- crates/browser/src/container.rs | 22 +- crates/config/src/validate.rs | 11 +- crates/cron/src/service.rs | 11 +- crates/gateway/src/auth_webauthn.rs | 16 +- crates/gateway/src/channel_events.rs | 52 ++-- crates/gateway/src/chat.rs | 20 +- crates/gateway/src/methods.rs | 8 +- crates/gateway/src/provider_setup.rs | 68 ++--- crates/gateway/src/server.rs | 59 ++-- crates/gateway/src/state.rs | 9 +- crates/mcp/src/manager.rs | 11 +- crates/mcp/src/registry.rs | 50 ++-- crates/metrics/src/store.rs | 18 +- crates/oauth/src/defaults.rs | 79 +++--- crates/telegram/src/handlers.rs | 29 +- crates/telegram/src/otp.rs | 9 +- crates/tools/src/sandbox.rs | 12 +- crates/tools/src/sandbox_packages.rs | 261 ++++++++---------- crates/tools/src/web_fetch.rs | 11 +- crates/tools/src/web_search.rs | 11 +- 26 files changed, 417 insertions(+), 601 deletions(-) diff --git a/crates/agents/src/model.rs b/crates/agents/src/model.rs index 1866bb9c..61dfbb51 100644 --- a/crates/agents/src/model.rs +++ b/crates/agents/src/model.rs @@ -473,14 +473,11 @@ mod tests { #[test] fn to_openai_assistant_with_tools() { - let msg = ChatMessage::assistant_with_tools( - Some("thinking".into()), - vec![ToolCall { - id: "call_1".into(), - name: "exec".into(), - arguments: serde_json::json!({"cmd": "ls"}), - }], - ); + let msg = ChatMessage::assistant_with_tools(Some("thinking".into()), vec![ToolCall { + id: "call_1".into(), + name: "exec".into(), + arguments: serde_json::json!({"cmd": "ls"}), + }]); let val = msg.to_openai_value(); assert_eq!(val["role"], "assistant"); assert_eq!(val["content"], "thinking"); diff --git a/crates/agents/src/providers/github_copilot.rs b/crates/agents/src/providers/github_copilot.rs index 69928566..8d5ebded 100644 --- a/crates/agents/src/providers/github_copilot.rs +++ b/crates/agents/src/providers/github_copilot.rs @@ -235,14 +235,11 @@ async fn fetch_valid_copilot_token( } let copilot_resp: CopilotTokenResponse = resp.json().await?; - let _ = token_store.save( - "github-copilot-api", - &OAuthTokens { - access_token: Secret::new(copilot_resp.token.clone()), - refresh_token: None, - expires_at: Some(copilot_resp.expires_at), - }, - ); + let _ = token_store.save("github-copilot-api", &OAuthTokens { + access_token: Secret::new(copilot_resp.token.clone()), + refresh_token: None, + expires_at: Some(copilot_resp.expires_at), + }); Ok(copilot_resp.token) } diff --git a/crates/agents/src/providers/mod.rs b/crates/agents/src/providers/mod.rs index 21575768..04b32116 100644 --- a/crates/agents/src/providers/mod.rs +++ b/crates/agents/src/providers/mod.rs @@ -1552,13 +1552,12 @@ mod tests { #[test] fn mistral_registers_with_api_key() { let mut config = ProvidersConfig::default(); - config.providers.insert( - "mistral".into(), - moltis_config::schema::ProviderEntry { + config + .providers + .insert("mistral".into(), moltis_config::schema::ProviderEntry { api_key: Some(secrecy::Secret::new("sk-test-mistral".into())), ..Default::default() - }, - ); + }); let reg = ProviderRegistry::from_env_with_config(&config); // Should have registered Mistral models @@ -1580,13 +1579,12 @@ mod tests { #[test] fn cerebras_registers_with_api_key() { let mut config = ProvidersConfig::default(); - config.providers.insert( - "cerebras".into(), - moltis_config::schema::ProviderEntry { + config + .providers + .insert("cerebras".into(), moltis_config::schema::ProviderEntry { api_key: Some(secrecy::Secret::new("sk-test-cerebras".into())), ..Default::default() - }, - ); + }); let reg = ProviderRegistry::from_env_with_config(&config); let cerebras_models: Vec<_> = reg @@ -1600,13 +1598,12 @@ mod tests { #[test] fn minimax_registers_with_api_key() { let mut config = ProvidersConfig::default(); - config.providers.insert( - "minimax".into(), - moltis_config::schema::ProviderEntry { + config + .providers + .insert("minimax".into(), moltis_config::schema::ProviderEntry { api_key: Some(secrecy::Secret::new("sk-test-minimax".into())), ..Default::default() - }, - ); + }); let reg = ProviderRegistry::from_env_with_config(&config); assert!(reg.list_models().iter().any(|m| m.provider == "minimax")); @@ -1615,13 +1612,12 @@ mod tests { #[test] fn moonshot_registers_with_api_key() { let mut config = ProvidersConfig::default(); - config.providers.insert( - "moonshot".into(), - moltis_config::schema::ProviderEntry { + config + .providers + .insert("moonshot".into(), moltis_config::schema::ProviderEntry { api_key: Some(secrecy::Secret::new("sk-test-moonshot".into())), ..Default::default() - }, - ); + }); let reg = ProviderRegistry::from_env_with_config(&config); assert!(reg.list_models().iter().any(|m| m.provider == "moonshot")); @@ -1631,13 +1627,12 @@ mod tests { fn openrouter_requires_model_in_config() { // OpenRouter has no default models — without a model in config it registers nothing. let mut config = ProvidersConfig::default(); - config.providers.insert( - "openrouter".into(), - moltis_config::schema::ProviderEntry { + config + .providers + .insert("openrouter".into(), moltis_config::schema::ProviderEntry { api_key: Some(secrecy::Secret::new("sk-test-or".into())), ..Default::default() - }, - ); + }); let reg = ProviderRegistry::from_env_with_config(&config); assert!(!reg.list_models().iter().any(|m| m.provider == "openrouter")); @@ -1646,14 +1641,13 @@ mod tests { #[test] fn openrouter_registers_with_model_in_config() { let mut config = ProvidersConfig::default(); - config.providers.insert( - "openrouter".into(), - moltis_config::schema::ProviderEntry { + config + .providers + .insert("openrouter".into(), moltis_config::schema::ProviderEntry { api_key: Some(secrecy::Secret::new("sk-test-or".into())), model: Some("anthropic/claude-3-haiku".into()), ..Default::default() - }, - ); + }); let reg = ProviderRegistry::from_env_with_config(&config); let or_models: Vec<_> = reg @@ -1669,13 +1663,12 @@ mod tests { fn ollama_registers_without_api_key_env() { // Ollama should use a dummy key if no env var is set. let mut config = ProvidersConfig::default(); - config.providers.insert( - "ollama".into(), - moltis_config::schema::ProviderEntry { + config + .providers + .insert("ollama".into(), moltis_config::schema::ProviderEntry { model: Some("llama3".into()), ..Default::default() - }, - ); + }); let reg = ProviderRegistry::from_env_with_config(&config); assert!(reg.list_models().iter().any(|m| m.provider == "ollama")); @@ -1685,13 +1678,12 @@ mod tests { #[test] fn venice_requires_model_in_config() { let mut config = ProvidersConfig::default(); - config.providers.insert( - "venice".into(), - moltis_config::schema::ProviderEntry { + config + .providers + .insert("venice".into(), moltis_config::schema::ProviderEntry { api_key: Some(secrecy::Secret::new("sk-test-venice".into())), ..Default::default() - }, - ); + }); let reg = ProviderRegistry::from_env_with_config(&config); assert!(!reg.list_models().iter().any(|m| m.provider == "venice")); @@ -1700,14 +1692,13 @@ mod tests { #[test] fn disabled_provider_not_registered() { let mut config = ProvidersConfig::default(); - config.providers.insert( - "mistral".into(), - moltis_config::schema::ProviderEntry { + config + .providers + .insert("mistral".into(), moltis_config::schema::ProviderEntry { api_key: Some(secrecy::Secret::new("sk-test".into())), enabled: false, ..Default::default() - }, - ); + }); let reg = ProviderRegistry::from_env_with_config(&config); assert!(!reg.list_models().iter().any(|m| m.provider == "mistral")); @@ -1727,14 +1718,13 @@ mod tests { #[test] fn custom_base_url_from_config() { let mut config = ProvidersConfig::default(); - config.providers.insert( - "mistral".into(), - moltis_config::schema::ProviderEntry { + config + .providers + .insert("mistral".into(), moltis_config::schema::ProviderEntry { api_key: Some(secrecy::Secret::new("sk-test".into())), base_url: Some("https://custom.mistral.example.com/v1".into()), ..Default::default() - }, - ); + }); let reg = ProviderRegistry::from_env_with_config(&config); assert!(reg.list_models().iter().any(|m| m.provider == "mistral")); @@ -1743,14 +1733,13 @@ mod tests { #[test] fn specific_model_override() { let mut config = ProvidersConfig::default(); - config.providers.insert( - "mistral".into(), - moltis_config::schema::ProviderEntry { + config + .providers + .insert("mistral".into(), moltis_config::schema::ProviderEntry { api_key: Some(secrecy::Secret::new("sk-test".into())), model: Some("mistral-small-latest".into()), ..Default::default() - }, - ); + }); let reg = ProviderRegistry::from_env_with_config(&config); let mistral_models: Vec<_> = reg @@ -1831,12 +1820,11 @@ mod tests { fn local_llm_requires_model_in_config() { // local-llm is a "bring your own model" provider — without a model it registers nothing. let mut config = ProvidersConfig::default(); - config.providers.insert( - "local".into(), - moltis_config::schema::ProviderEntry { + config + .providers + .insert("local".into(), moltis_config::schema::ProviderEntry { ..Default::default() - }, - ); + }); let reg = ProviderRegistry::from_env_with_config(&config); assert!(!reg.list_models().iter().any(|m| m.provider == "local-llm")); @@ -1846,13 +1834,12 @@ mod tests { #[test] fn local_llm_registers_with_model_in_config() { let mut config = ProvidersConfig::default(); - config.providers.insert( - "local".into(), - moltis_config::schema::ProviderEntry { + config + .providers + .insert("local".into(), moltis_config::schema::ProviderEntry { model: Some("qwen2.5-coder-7b-q4_k_m".into()), ..Default::default() - }, - ); + }); let reg = ProviderRegistry::from_env_with_config(&config); let local_models: Vec<_> = reg @@ -1868,14 +1855,13 @@ mod tests { #[test] fn local_llm_disabled_not_registered() { let mut config = ProvidersConfig::default(); - config.providers.insert( - "local".into(), - moltis_config::schema::ProviderEntry { + config + .providers + .insert("local".into(), moltis_config::schema::ProviderEntry { enabled: false, model: Some("qwen2.5-coder-7b-q4_k_m".into()), ..Default::default() - }, - ); + }); let reg = ProviderRegistry::from_env_with_config(&config); assert!(!reg.list_models().iter().any(|m| m.provider == "local-llm")); diff --git a/crates/agents/src/providers/openai_codex.rs b/crates/agents/src/providers/openai_codex.rs index e0de2c53..2f17e23d 100644 --- a/crates/agents/src/providers/openai_codex.rs +++ b/crates/agents/src/providers/openai_codex.rs @@ -940,14 +940,11 @@ mod tests { #[test] fn convert_messages_tool_call_and_result() { let messages = vec![ - ChatMessage::assistant_with_tools( - None, - vec![ToolCall { - id: "call_1".to_string(), - name: "get_time".to_string(), - arguments: serde_json::json!({}), - }], - ), + ChatMessage::assistant_with_tools(None, vec![ToolCall { + id: "call_1".to_string(), + name: "get_time".to_string(), + arguments: serde_json::json!({}), + }]), ChatMessage::tool("call_1", "12:00"), ]; let converted = OpenAiCodexProvider::convert_messages(&messages); @@ -1072,14 +1069,11 @@ mod tests { .to_string(); let messages = vec![ ChatMessage::user("Take a screenshot"), - ChatMessage::assistant_with_tools( - None, - vec![ToolCall { - id: "call_screenshot".to_string(), - name: "browser_screenshot".to_string(), - arguments: serde_json::json!({}), - }], - ), + ChatMessage::assistant_with_tools(None, vec![ToolCall { + id: "call_screenshot".to_string(), + name: "browser_screenshot".to_string(), + arguments: serde_json::json!({}), + }]), ChatMessage::tool("call_screenshot", &tool_output), ChatMessage::assistant("Here is the screenshot."), ]; diff --git a/crates/agents/src/providers/openai_compat.rs b/crates/agents/src/providers/openai_compat.rs index e2b0b3e1..7788c3cf 100644 --- a/crates/agents/src/providers/openai_compat.rs +++ b/crates/agents/src/providers/openai_compat.rs @@ -608,10 +608,9 @@ mod tests { let events = finalize_stream(&state); assert_eq!(events.len(), 2); - assert!(matches!( - &events[0], - StreamEvent::ToolCallComplete { index: 0 } - )); + assert!(matches!(&events[0], StreamEvent::ToolCallComplete { + index: 0 + })); assert!(matches!( &events[1], StreamEvent::Done(usage) if usage.input_tokens == 10 && usage.output_tokens == 5 diff --git a/crates/agents/src/tool_registry.rs b/crates/agents/src/tool_registry.rs index bebe4985..89234c7d 100644 --- a/crates/agents/src/tool_registry.rs +++ b/crates/agents/src/tool_registry.rs @@ -52,25 +52,19 @@ impl ToolRegistry { /// Register a built-in tool. pub fn register(&mut self, tool: Box) { let name = tool.name().to_string(); - self.tools.insert( - name, - ToolEntry { - tool: Arc::from(tool), - source: ToolSource::Builtin, - }, - ); + self.tools.insert(name, ToolEntry { + tool: Arc::from(tool), + source: ToolSource::Builtin, + }); } /// Register a tool from an MCP server. pub fn register_mcp(&mut self, tool: Box, server: String) { let name = tool.name().to_string(); - self.tools.insert( - name, - ToolEntry { - tool: Arc::from(tool), - source: ToolSource::Mcp { server }, - }, - ); + self.tools.insert(name, ToolEntry { + tool: Arc::from(tool), + source: ToolSource::Mcp { server }, + }); } pub fn unregister(&mut self, name: &str) -> bool { @@ -124,13 +118,10 @@ impl ToolRegistry { .iter() .filter(|(name, _)| !name.starts_with(prefix)) .map(|(name, entry)| { - ( - name.clone(), - ToolEntry { - tool: Arc::clone(&entry.tool), - source: entry.source.clone(), - }, - ) + (name.clone(), ToolEntry { + tool: Arc::clone(&entry.tool), + source: entry.source.clone(), + }) }) .collect(); ToolRegistry { tools } @@ -143,13 +134,10 @@ impl ToolRegistry { .iter() .filter(|(_, entry)| !matches!(entry.source, ToolSource::Mcp { .. })) .map(|(name, entry)| { - ( - name.clone(), - ToolEntry { - tool: Arc::clone(&entry.tool), - source: entry.source.clone(), - }, - ) + (name.clone(), ToolEntry { + tool: Arc::clone(&entry.tool), + source: entry.source.clone(), + }) }) .collect(); ToolRegistry { tools } @@ -162,13 +150,10 @@ impl ToolRegistry { .iter() .filter(|(name, _)| !exclude.contains(&name.as_str())) .map(|(name, entry)| { - ( - name.clone(), - ToolEntry { - tool: Arc::clone(&entry.tool), - source: entry.source.clone(), - }, - ) + (name.clone(), ToolEntry { + tool: Arc::clone(&entry.tool), + source: entry.source.clone(), + }) }) .collect(); ToolRegistry { tools } @@ -184,13 +169,10 @@ impl ToolRegistry { .iter() .filter(|(name, _)| predicate(name)) .map(|(name, entry)| { - ( - name.clone(), - ToolEntry { - tool: Arc::clone(&entry.tool), - source: entry.source.clone(), - }, - ) + (name.clone(), ToolEntry { + tool: Arc::clone(&entry.tool), + source: entry.source.clone(), + }) }) .collect(); ToolRegistry { tools } diff --git a/crates/browser/src/container.rs b/crates/browser/src/container.rs index b68a61d1..6fa6f1d7 100644 --- a/crates/browser/src/container.rs +++ b/crates/browser/src/container.rs @@ -700,13 +700,10 @@ mod tests { fn test_parse_docker_container_names_filters_prefix() { let input = b"moltis-test-browser-abc\nother-container\nmoltis-test-browser-def\n"; let parsed = parse_docker_container_names(input, "moltis-test-browser"); - assert_eq!( - parsed, - vec![ - "moltis-test-browser-abc".to_string(), - "moltis-test-browser-def".to_string() - ] - ); + assert_eq!(parsed, vec![ + "moltis-test-browser-abc".to_string(), + "moltis-test-browser-def".to_string() + ]); } #[cfg(target_os = "macos")] @@ -718,13 +715,10 @@ mod tests { {"configuration":{"id":"moltis-test-browser-456"}} ]"#; let parsed = parse_apple_container_names_for_prefix(json, "moltis-test-browser").unwrap(); - assert_eq!( - parsed, - vec![ - "moltis-test-browser-123".to_string(), - "moltis-test-browser-456".to_string() - ] - ); + assert_eq!(parsed, vec![ + "moltis-test-browser-123".to_string(), + "moltis-test-browser-456".to_string() + ]); } #[test] diff --git a/crates/config/src/validate.rs b/crates/config/src/validate.rs index 1a510515..8cab0016 100644 --- a/crates/config/src/validate.rs +++ b/crates/config/src/validate.rs @@ -276,13 +276,10 @@ fn build_schema_map() -> KnownKeys { ("update_repository_url", Leaf), ])), ), - ( - "providers", - MapWithFields { - value: Box::new(provider_entry()), - fields: HashMap::from([("offered", Array(Box::new(Leaf)))]), - }, - ), + ("providers", MapWithFields { + value: Box::new(provider_entry()), + fields: HashMap::from([("offered", Array(Box::new(Leaf)))]), + }), ( "chat", Struct(HashMap::from([ diff --git a/crates/cron/src/service.rs b/crates/cron/src/service.rs index e21c3f50..5fc82385 100644 --- a/crates/cron/src/service.rs +++ b/crates/cron/src/service.rs @@ -801,13 +801,10 @@ mod tests { .unwrap(); let updated = svc - .update( - &job.id, - CronJobPatch { - name: Some("renamed".into()), - ..Default::default() - }, - ) + .update(&job.id, CronJobPatch { + name: Some("renamed".into()), + ..Default::default() + }) .await .unwrap(); diff --git a/crates/gateway/src/auth_webauthn.rs b/crates/gateway/src/auth_webauthn.rs index 2527b9e7..0f0daa8c 100644 --- a/crates/gateway/src/auth_webauthn.rs +++ b/crates/gateway/src/auth_webauthn.rs @@ -83,13 +83,11 @@ impl WebAuthnState { .map_err(|e| anyhow::anyhow!("start_passkey_registration: {e}"))?; let challenge_id = uuid::Uuid::new_v4().to_string(); - self.pending_registrations.insert( - challenge_id.clone(), - PendingRegistration { + self.pending_registrations + .insert(challenge_id.clone(), PendingRegistration { state: reg_state, created_at: Instant::now(), - }, - ); + }); Ok((challenge_id, ccr)) } @@ -134,13 +132,11 @@ impl WebAuthnState { .map_err(|e| anyhow::anyhow!("start_passkey_authentication: {e}"))?; let challenge_id = uuid::Uuid::new_v4().to_string(); - self.pending_authentications.insert( - challenge_id.clone(), - PendingAuthentication { + self.pending_authentications + .insert(challenge_id.clone(), PendingAuthentication { state: auth_state, created_at: Instant::now(), - }, - ); + }); Ok((challenge_id, rcr)) } diff --git a/crates/gateway/src/channel_events.rs b/crates/gateway/src/channel_events.rs index f1a7a51a..63cda37f 100644 --- a/crates/gateway/src/channel_events.rs +++ b/crates/gateway/src/channel_events.rs @@ -72,15 +72,10 @@ impl ChannelEventSink for GatewayChannelEventSink { return; }, }; - broadcast( - state, - "channel", - payload, - BroadcastOpts { - drop_if_slow: true, - ..Default::default() - }, - ) + broadcast(state, "channel", payload, BroadcastOpts { + drop_if_slow: true, + ..Default::default() + }) .await; } } @@ -113,15 +108,10 @@ impl ChannelEventSink for GatewayChannelEventSink { "sessionKey": &session_key, "messageIndex": msg_index, }); - broadcast( - state, - "chat", - payload, - BroadcastOpts { - drop_if_slow: true, - ..Default::default() - }, - ) + broadcast(state, "chat", payload, BroadcastOpts { + drop_if_slow: true, + ..Default::default() + }) .await; // Register the reply target so the chat "final" broadcast can @@ -349,15 +339,10 @@ impl ChannelEventSink for GatewayChannelEventSink { return; }, }; - broadcast( - state, - "channel", - payload, - BroadcastOpts { - drop_if_slow: true, - ..Default::default() - }, - ) + broadcast(state, "channel", payload, BroadcastOpts { + drop_if_slow: true, + ..Default::default() + }) .await; } else { warn!("request_disable_account: gateway not ready"); @@ -559,15 +544,10 @@ impl ChannelEventSink for GatewayChannelEventSink { "messageIndex": msg_index, "hasAttachments": true, }); - broadcast( - state, - "chat", - payload, - BroadcastOpts { - drop_if_slow: true, - ..Default::default() - }, - ) + broadcast(state, "chat", payload, BroadcastOpts { + drop_if_slow: true, + ..Default::default() + }) .await; // Register the reply target diff --git a/crates/gateway/src/chat.rs b/crates/gateway/src/chat.rs index 2579281b..a5d86679 100644 --- a/crates/gateway/src/chat.rs +++ b/crates/gateway/src/chat.rs @@ -5884,12 +5884,10 @@ mod tests { ); let disabled = Arc::new(RwLock::new(DisabledModelsStore::default())); - let service = LiveModelService::new( - Arc::new(RwLock::new(registry)), - disabled, - vec![], - vec!["opus".into()], - ); + let service = + LiveModelService::new(Arc::new(RwLock::new(registry)), disabled, vec![], vec![ + "opus".into(), + ]); // list() should only contain opus. let result = service.list().await.unwrap(); @@ -5933,12 +5931,10 @@ mod tests { ); let disabled = Arc::new(RwLock::new(DisabledModelsStore::default())); - let service = LiveModelService::new( - Arc::new(RwLock::new(registry)), - disabled, - vec![], - vec!["opus".into()], - ); + let service = + LiveModelService::new(Arc::new(RwLock::new(registry)), disabled, vec![], vec![ + "opus".into(), + ]); let result = service.list().await.unwrap(); let arr = result.as_array().unwrap(); diff --git a/crates/gateway/src/methods.rs b/crates/gateway/src/methods.rs index c52312d8..106addcc 100644 --- a/crates/gateway/src/methods.rs +++ b/crates/gateway/src/methods.rs @@ -5520,10 +5520,10 @@ mod tests { VoiceProviderId::parse_tts_list_id, ); let ids: Vec<_> = filtered.into_iter().map(|p| p.id).collect(); - assert_eq!( - ids, - vec![VoiceProviderId::OpenaiTts, VoiceProviderId::Piper] - ); + assert_eq!(ids, vec![ + VoiceProviderId::OpenaiTts, + VoiceProviderId::Piper + ]); } #[test] diff --git a/crates/gateway/src/provider_setup.rs b/crates/gateway/src/provider_setup.rs index e030f75a..4d6ce3eb 100644 --- a/crates/gateway/src/provider_setup.rs +++ b/crates/gateway/src/provider_setup.rs @@ -86,14 +86,11 @@ impl KeyStore { return old_format .into_iter() .map(|(k, v)| { - ( - k, - ProviderConfig { - api_key: Some(v), - base_url: None, - model: None, - }, - ) + (k, ProviderConfig { + api_key: Some(v), + base_url: None, + model: None, + }) }) .collect(); } @@ -1890,13 +1887,12 @@ mod tests { .expect("openai-codex should exist"); let mut config = ProvidersConfig::default(); - config.providers.insert( - "openai-codex".into(), - ProviderEntry { + config + .providers + .insert("openai-codex".into(), ProviderEntry { enabled: false, ..Default::default() - }, - ); + }); assert!(!svc.is_provider_configured(&provider, &config)); } @@ -1923,13 +1919,10 @@ mod tests { store.save("anthropic", "sk-saved").unwrap(); let mut base = ProvidersConfig::default(); - base.providers.insert( - "anthropic".into(), - ProviderEntry { - api_key: Some(Secret::new("sk-config".into())), - ..Default::default() - }, - ); + base.providers.insert("anthropic".into(), ProviderEntry { + api_key: Some(Secret::new("sk-config".into())), + ..Default::default() + }); let merged = config_with_saved_keys(&base, &store); let entry = merged.get("anthropic").unwrap(); // Config key takes precedence over saved key. @@ -2128,14 +2121,11 @@ mod tests { Some(&home) )); - home.save( - "github-copilot", - &OAuthTokens { - access_token: Secret::new("home-token".to_string()), - refresh_token: None, - expires_at: None, - }, - ) + home.save("github-copilot", &OAuthTokens { + access_token: Secret::new("home-token".to_string()), + refresh_token: None, + expires_at: None, + }) .expect("save home token"); assert!(has_oauth_tokens_for_provider( @@ -2330,23 +2320,17 @@ mod tests { let mut empty = ProvidersConfig::default(); assert!(!has_explicit_provider_settings(&empty)); - empty.providers.insert( - "openai".into(), - ProviderEntry { - api_key: Some(Secret::new("sk-test".into())), - ..Default::default() - }, - ); + empty.providers.insert("openai".into(), ProviderEntry { + api_key: Some(Secret::new("sk-test".into())), + ..Default::default() + }); assert!(has_explicit_provider_settings(&empty)); let mut model_only = ProvidersConfig::default(); - model_only.providers.insert( - "ollama".into(), - ProviderEntry { - model: Some("llama3".into()), - ..Default::default() - }, - ); + model_only.providers.insert("ollama".into(), ProviderEntry { + model: Some("llama3".into()), + ..Default::default() + }); assert!(has_explicit_provider_settings(&model_only)); } diff --git a/crates/gateway/src/server.rs b/crates/gateway/src/server.rs index de0bb717..a75bb0c7 100644 --- a/crates/gateway/src/server.rs +++ b/crates/gateway/src/server.rs @@ -129,14 +129,11 @@ impl moltis_tools::location::LocationRequester for GatewayLocationRequester { { let mut inner_w = self.state.inner.write().await; let invokes = &mut inner_w.pending_invokes; - invokes.insert( - request_id.clone(), - crate::state::PendingInvoke { - request_id: request_id.clone(), - sender: tx, - created_at: std::time::Instant::now(), - }, - ); + invokes.insert(request_id.clone(), crate::state::PendingInvoke { + request_id: request_id.clone(), + sender: tx, + created_at: std::time::Instant::now(), + }); } // Wait up to 30 seconds for the user to grant/deny permission. @@ -250,14 +247,13 @@ impl moltis_tools::location::LocationRequester for GatewayLocationRequester { let (tx, rx) = tokio::sync::oneshot::channel(); { let mut inner = self.state.inner.write().await; - inner.pending_invokes.insert( - pending_key.clone(), - crate::state::PendingInvoke { + inner + .pending_invokes + .insert(pending_key.clone(), crate::state::PendingInvoke { request_id: pending_key.clone(), sender: tx, created_at: std::time::Instant::now(), - }, - ); + }); } // Wait up to 60 seconds — user needs to navigate Telegram's UI. @@ -1031,17 +1027,16 @@ pub async fn start_gateway( "sse" => moltis_mcp::registry::TransportType::Sse, _ => moltis_mcp::registry::TransportType::Stdio, }; - merged.servers.insert( - name.clone(), - moltis_mcp::McpServerConfig { + merged + .servers + .insert(name.clone(), moltis_mcp::McpServerConfig { command: entry.command.clone(), args: entry.args.clone(), env: entry.env.clone(), enabled: entry.enabled, transport, url: entry.url.clone(), - }, - ); + }); } } mcp_configured_count = merged.servers.values().filter(|s| s.enabled).count(); @@ -2942,15 +2937,10 @@ pub async fn start_gateway( } }; if changed && let Ok(payload) = serde_json::to_value(&next) { - broadcast( - &update_state, - "update.available", - payload, - BroadcastOpts { - drop_if_slow: true, - ..Default::default() - }, - ) + broadcast(&update_state, "update.available", payload, BroadcastOpts { + drop_if_slow: true, + ..Default::default() + }) .await; } }, @@ -3013,15 +3003,12 @@ pub async fn start_gateway( .by_provider .iter() .map(|(name, metrics)| { - ( - name.clone(), - moltis_metrics::ProviderTokens { - input_tokens: metrics.input_tokens, - output_tokens: metrics.output_tokens, - completions: metrics.completions, - errors: metrics.errors, - }, - ) + (name.clone(), moltis_metrics::ProviderTokens { + input_tokens: metrics.input_tokens, + output_tokens: metrics.output_tokens, + completions: metrics.completions, + errors: metrics.errors, + }) }) .collect(); diff --git a/crates/gateway/src/state.rs b/crates/gateway/src/state.rs index 7460fc6d..a83991e0 100644 --- a/crates/gateway/src/state.rs +++ b/crates/gateway/src/state.rs @@ -183,12 +183,9 @@ impl DedupeCache { { self.entries.remove(&oldest_key); } - self.entries.insert( - key.to_string(), - DedupeEntry { - inserted_at: Instant::now(), - }, - ); + self.entries.insert(key.to_string(), DedupeEntry { + inserted_at: Instant::now(), + }); false } diff --git a/crates/mcp/src/manager.rs b/crates/mcp/src/manager.rs index 8965a9df..8c87fffb 100644 --- a/crates/mcp/src/manager.rs +++ b/crates/mcp/src/manager.rs @@ -318,13 +318,10 @@ mod tests { #[tokio::test] async fn test_status_shows_stopped_for_configured_but_not_started() { let mut reg = McpRegistry::new(); - reg.servers.insert( - "test".into(), - McpServerConfig { - command: "echo".into(), - ..Default::default() - }, - ); + reg.servers.insert("test".into(), McpServerConfig { + command: "echo".into(), + ..Default::default() + }); let mgr = McpManager::new(reg); let statuses = mgr.status_all().await; diff --git a/crates/mcp/src/registry.rs b/crates/mcp/src/registry.rs index b309e7b1..5e0e5f83 100644 --- a/crates/mcp/src/registry.rs +++ b/crates/mcp/src/registry.rs @@ -167,13 +167,10 @@ mod tests { #[test] fn test_registry_add_remove() { let mut reg = McpRegistry::new(); - reg.servers.insert( - "test".into(), - McpServerConfig { - command: "echo".into(), - ..Default::default() - }, - ); + reg.servers.insert("test".into(), McpServerConfig { + command: "echo".into(), + ..Default::default() + }); assert_eq!(reg.list().len(), 1); assert!(reg.get("test").is_some()); @@ -184,13 +181,10 @@ mod tests { #[test] fn test_registry_enable_disable() { let mut reg = McpRegistry::new(); - reg.servers.insert( - "srv".into(), - McpServerConfig { - command: "test".into(), - ..Default::default() - }, - ); + reg.servers.insert("srv".into(), McpServerConfig { + command: "test".into(), + ..Default::default() + }); assert_eq!(reg.enabled_servers().len(), 1); @@ -201,14 +195,11 @@ mod tests { #[test] fn test_registry_serialization() { let mut reg = McpRegistry::new(); - reg.servers.insert( - "fs".into(), - McpServerConfig { - command: "mcp-server-filesystem".into(), - args: vec!["/tmp".into()], - ..Default::default() - }, - ); + reg.servers.insert("fs".into(), McpServerConfig { + command: "mcp-server-filesystem".into(), + args: vec!["/tmp".into()], + ..Default::default() + }); let json = serde_json::to_string(®).unwrap(); let parsed: McpRegistry = serde_json::from_str(&json).unwrap(); @@ -229,15 +220,12 @@ mod tests { let path = dir.path().join("mcp.json"); let mut reg = McpRegistry::load(&path).unwrap(); - reg.servers.insert( - "test".into(), - McpServerConfig { - command: "echo".into(), - args: vec!["hello".into()], - env: HashMap::from([("FOO".into(), "bar".into())]), - ..Default::default() - }, - ); + reg.servers.insert("test".into(), McpServerConfig { + command: "echo".into(), + args: vec!["hello".into()], + env: HashMap::from([("FOO".into(), "bar".into())]), + ..Default::default() + }); reg.save().unwrap(); let loaded = McpRegistry::load(&path).unwrap(); diff --git a/crates/metrics/src/store.rs b/crates/metrics/src/store.rs index 95b457f9..d783e9d6 100644 --- a/crates/metrics/src/store.rs +++ b/crates/metrics/src/store.rs @@ -343,24 +343,22 @@ mod tests { let store = SqliteMetricsStore::in_memory().await.unwrap(); let mut point = make_point(1000, 10); - point.by_provider.insert( - "anthropic".to_string(), - ProviderTokens { + point + .by_provider + .insert("anthropic".to_string(), ProviderTokens { input_tokens: 500, output_tokens: 200, completions: 5, errors: 0, - }, - ); - point.by_provider.insert( - "openai".to_string(), - ProviderTokens { + }); + point + .by_provider + .insert("openai".to_string(), ProviderTokens { input_tokens: 300, output_tokens: 100, completions: 5, errors: 1, - }, - ); + }); store.save_point(&point).await.unwrap(); diff --git a/crates/oauth/src/defaults.rs b/crates/oauth/src/defaults.rs index c08e30dc..e1ead945 100644 --- a/crates/oauth/src/defaults.rs +++ b/crates/oauth/src/defaults.rs @@ -8,50 +8,41 @@ fn builtin_defaults() -> HashMap { // GitHub Copilot uses device flow (handled by the provider itself), // but we store a config entry so `load_oauth_config` returns Some // and the gateway recognises it as an OAuth provider. - m.insert( - "github-copilot".into(), - OAuthConfig { - client_id: "Iv1.b507a08c87ecfe98".into(), - auth_url: "https://github.com/login/device/code".into(), - token_url: "https://github.com/login/oauth/access_token".into(), - redirect_uri: String::new(), - scopes: vec![], - extra_auth_params: vec![], - device_flow: true, - }, - ); - m.insert( - "kimi-code".into(), - OAuthConfig { - client_id: "17e5f671-d194-4dfb-9706-5516cb48c098".into(), - auth_url: "https://auth.kimi.com/api/oauth/device_authorization".into(), - token_url: "https://auth.kimi.com/api/oauth/token".into(), - redirect_uri: String::new(), - scopes: vec![], - extra_auth_params: vec![], - device_flow: true, - }, - ); - m.insert( - "openai-codex".into(), - OAuthConfig { - client_id: "app_EMoamEEZ73f0CkXaXp7hrann".into(), - auth_url: "https://auth.openai.com/oauth/authorize".into(), - token_url: "https://auth.openai.com/oauth/token".into(), - redirect_uri: "http://localhost:1455/auth/callback".into(), - scopes: vec![ - "openid".into(), - "profile".into(), - "email".into(), - "offline_access".into(), - ], - extra_auth_params: vec![ - ("id_token_add_organizations".into(), "true".into()), - ("codex_cli_simplified_flow".into(), "true".into()), - ], - device_flow: false, - }, - ); + m.insert("github-copilot".into(), OAuthConfig { + client_id: "Iv1.b507a08c87ecfe98".into(), + auth_url: "https://github.com/login/device/code".into(), + token_url: "https://github.com/login/oauth/access_token".into(), + redirect_uri: String::new(), + scopes: vec![], + extra_auth_params: vec![], + device_flow: true, + }); + m.insert("kimi-code".into(), OAuthConfig { + client_id: "17e5f671-d194-4dfb-9706-5516cb48c098".into(), + auth_url: "https://auth.kimi.com/api/oauth/device_authorization".into(), + token_url: "https://auth.kimi.com/api/oauth/token".into(), + redirect_uri: String::new(), + scopes: vec![], + extra_auth_params: vec![], + device_flow: true, + }); + m.insert("openai-codex".into(), OAuthConfig { + client_id: "app_EMoamEEZ73f0CkXaXp7hrann".into(), + auth_url: "https://auth.openai.com/oauth/authorize".into(), + token_url: "https://auth.openai.com/oauth/token".into(), + redirect_uri: "http://localhost:1455/auth/callback".into(), + scopes: vec![ + "openid".into(), + "profile".into(), + "email".into(), + "offline_access".into(), + ], + extra_auth_params: vec![ + ("id_token_add_organizations".into(), "true".into()), + ("codex_cli_simplified_flow".into(), "true".into()), + ], + device_flow: false, + }); m } diff --git a/crates/telegram/src/handlers.rs b/crates/telegram/src/handlers.rs index 4f7cb808..efd43281 100644 --- a/crates/telegram/src/handlers.rs +++ b/crates/telegram/src/handlers.rs @@ -1746,23 +1746,20 @@ mod tests { { let mut map = accounts.write().expect("accounts write lock"); - map.insert( - account_id.to_string(), - AccountState { - bot: bot.clone(), - bot_username: Some("test_bot".into()), - account_id: account_id.to_string(), - config: TelegramAccountConfig { - token: Secret::new("test-token".to_string()), - ..Default::default() - }, - outbound: Arc::clone(&outbound), - cancel: CancellationToken::new(), - message_log: None, - event_sink: Some(Arc::clone(&sink) as Arc), - otp: std::sync::Mutex::new(OtpState::new(300)), + map.insert(account_id.to_string(), AccountState { + bot: bot.clone(), + bot_username: Some("test_bot".into()), + account_id: account_id.to_string(), + config: TelegramAccountConfig { + token: Secret::new("test-token".to_string()), + ..Default::default() }, - ); + outbound: Arc::clone(&outbound), + cancel: CancellationToken::new(), + message_log: None, + event_sink: Some(Arc::clone(&sink) as Arc), + otp: std::sync::Mutex::new(OtpState::new(300)), + }); } let msg: Message = serde_json::from_value(json!({ diff --git a/crates/telegram/src/otp.rs b/crates/telegram/src/otp.rs index 4d1ebee4..bdec9f24 100644 --- a/crates/telegram/src/otp.rs +++ b/crates/telegram/src/otp.rs @@ -158,12 +158,9 @@ impl OtpState { challenge.attempts += 1; if challenge.attempts >= MAX_ATTEMPTS { self.challenges.remove(peer_id); - self.lockouts.insert( - peer_id.to_string(), - Lockout { - until: now + self.cooldown, - }, - ); + self.lockouts.insert(peer_id.to_string(), Lockout { + until: now + self.cooldown, + }); return OtpVerifyResult::LockedOut; } diff --git a/crates/tools/src/sandbox.rs b/crates/tools/src/sandbox.rs index a63e0d3b..1ed52987 100644 --- a/crates/tools/src/sandbox.rs +++ b/crates/tools/src/sandbox.rs @@ -1741,10 +1741,14 @@ mod tests { }; let docker = DockerSandbox::new(config); let args = docker.resource_args(); - assert_eq!( - args, - vec!["--memory", "256M", "--cpus", "0.5", "--pids-limit", "50"] - ); + assert_eq!(args, vec![ + "--memory", + "256M", + "--cpus", + "0.5", + "--pids-limit", + "50" + ]); } #[test] diff --git a/crates/tools/src/sandbox_packages.rs b/crates/tools/src/sandbox_packages.rs index ea8bbaed..c805facf 100644 --- a/crates/tools/src/sandbox_packages.rs +++ b/crates/tools/src/sandbox_packages.rs @@ -35,156 +35,123 @@ use crate::{exec::ExecOpts, sandbox::SandboxRouter}; /// in "Other". Library/dev/font packages are filtered out before /// categorization (see [`is_infrastructure_package`]). const CATEGORY_MAP: &[(&str, &[&str])] = &[ - ( - "Networking", - &[ - "curl", - "wget", - "ca-certificates", - "dnsutils", - "netcat-openbsd", - "openssh-client", - "iproute2", - "net-tools", - ], - ), - ( - "Languages", - &[ - "python3", - "python3-pip", - "python3-venv", - "python-is-python3", - "nodejs", - "npm", - "ruby", - ], - ), - ( - "Build tools", - &[ - "build-essential", - "clang", - "pkg-config", - "autoconf", - "automake", - "libtool", - "bison", - "flex", - "dpkg-dev", - "fakeroot", - ], - ), - ( - "Compression", - &[ - "zip", - "unzip", - "bzip2", - "xz-utils", - "p7zip-full", - "tar", - "zstd", - "lz4", - "pigz", - ], - ), - ( - "CLI utilities", - &[ - "git", - "gnupg2", - "jq", - "rsync", - "file", - "tree", - "sqlite3", - "sudo", - "locales", - "tzdata", - "shellcheck", - "patchelf", - "tmux", - ], - ), + ("Networking", &[ + "curl", + "wget", + "ca-certificates", + "dnsutils", + "netcat-openbsd", + "openssh-client", + "iproute2", + "net-tools", + ]), + ("Languages", &[ + "python3", + "python3-pip", + "python3-venv", + "python-is-python3", + "nodejs", + "npm", + "ruby", + ]), + ("Build tools", &[ + "build-essential", + "clang", + "pkg-config", + "autoconf", + "automake", + "libtool", + "bison", + "flex", + "dpkg-dev", + "fakeroot", + ]), + ("Compression", &[ + "zip", + "unzip", + "bzip2", + "xz-utils", + "p7zip-full", + "tar", + "zstd", + "lz4", + "pigz", + ]), + ("CLI utilities", &[ + "git", + "gnupg2", + "jq", + "rsync", + "file", + "tree", + "sqlite3", + "sudo", + "locales", + "tzdata", + "shellcheck", + "patchelf", + "tmux", + ]), ("Text processing", &["ripgrep", "fd-find", "yq"]), ("Browser automation", &["chromium"]), - ( - "Image processing", - &[ - "imagemagick", - "graphicsmagick", - "libvips-tools", - "pngquant", - "optipng", - "jpegoptim", - "webp", - "libimage-exiftool-perl", - ], - ), - ( - "Audio/video", - &[ - "ffmpeg", - "sox", - "lame", - "flac", - "vorbis-tools", - "opus-tools", - "mediainfo", - ], - ), - ( - "Documents", - &[ - "pandoc", - "poppler-utils", - "ghostscript", - "texlive-latex-base", - "texlive-latex-extra", - "texlive-fonts-recommended", - "antiword", - "catdoc", - "unrtf", - "libreoffice-core", - "libreoffice-writer", - ], - ), - ( - "Data processing", - &[ - "csvtool", - "xmlstarlet", - "html2text", - "dos2unix", - "miller", - "datamash", - ], - ), - ( - "GIS/maps", - &[ - "gdal-bin", - "mapnik-utils", - "osm2pgsql", - "osmium-tool", - "osmctools", - "python3-mapnik", - ], - ), + ("Image processing", &[ + "imagemagick", + "graphicsmagick", + "libvips-tools", + "pngquant", + "optipng", + "jpegoptim", + "webp", + "libimage-exiftool-perl", + ]), + ("Audio/video", &[ + "ffmpeg", + "sox", + "lame", + "flac", + "vorbis-tools", + "opus-tools", + "mediainfo", + ]), + ("Documents", &[ + "pandoc", + "poppler-utils", + "ghostscript", + "texlive-latex-base", + "texlive-latex-extra", + "texlive-fonts-recommended", + "antiword", + "catdoc", + "unrtf", + "libreoffice-core", + "libreoffice-writer", + ]), + ("Data processing", &[ + "csvtool", + "xmlstarlet", + "html2text", + "dos2unix", + "miller", + "datamash", + ]), + ("GIS/maps", &[ + "gdal-bin", + "mapnik-utils", + "osm2pgsql", + "osmium-tool", + "osmctools", + "python3-mapnik", + ]), ("CalDAV/CardDAV", &["vdirsyncer", "khal", "python3-caldav"]), - ( - "Email", - &[ - "isync", - "offlineimap3", - "notmuch", - "notmuch-mutt", - "aerc", - "mutt", - "neomutt", - ], - ), + ("Email", &[ + "isync", + "offlineimap3", + "notmuch", + "notmuch-mutt", + "aerc", + "mutt", + "neomutt", + ]), ("Newsgroups (NNTP)", &["tin", "slrn"]), ("Messaging APIs", &["python3-discord"]), ]; diff --git a/crates/tools/src/web_fetch.rs b/crates/tools/src/web_fetch.rs index 7e383b32..c12e704e 100644 --- a/crates/tools/src/web_fetch.rs +++ b/crates/tools/src/web_fetch.rs @@ -65,13 +65,10 @@ impl WebFetchTool { let now = Instant::now(); cache.retain(|_, e| e.expires_at > now); } - cache.insert( - key, - CacheEntry { - value, - expires_at: Instant::now() + self.cache_ttl, - }, - ); + cache.insert(key, CacheEntry { + value, + expires_at: Instant::now() + self.cache_ttl, + }); } } diff --git a/crates/tools/src/web_search.rs b/crates/tools/src/web_search.rs index d5404fdb..d1112725 100644 --- a/crates/tools/src/web_search.rs +++ b/crates/tools/src/web_search.rs @@ -181,13 +181,10 @@ impl WebSearchTool { let now = Instant::now(); cache.retain(|_, e| e.expires_at > now); } - cache.insert( - key, - CacheEntry { - value, - expires_at: Instant::now() + self.cache_ttl, - }, - ); + cache.insert(key, CacheEntry { + value, + expires_at: Instant::now() + self.cache_ttl, + }); } } From 13ff2bef7820854b450aaeb23375539c49b0bd19 Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Wed, 11 Feb 2026 13:24:51 -0800 Subject: [PATCH 15/16] fix(channels): allow unwrap in test module for clippy --- crates/channels/src/plugin.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/channels/src/plugin.rs b/crates/channels/src/plugin.rs index fe741608..93c38a20 100644 --- a/crates/channels/src/plugin.rs +++ b/crates/channels/src/plugin.rs @@ -376,6 +376,7 @@ pub trait ChannelStreamOutbound: Send + Sync { } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use super::*; From 2c7df7a2b6a9f49d7e787078ac8e0314d8f29c2d Mon Sep 17 00:00:00 2001 From: Fabien Penso Date: Wed, 11 Feb 2026 13:28:04 -0800 Subject: [PATCH 16/16] fix(whatsapp): allow unwrap in test modules for clippy --- crates/whatsapp/src/access.rs | 1 + crates/whatsapp/src/config.rs | 1 + crates/whatsapp/src/memory_store.rs | 1 + crates/whatsapp/src/otp.rs | 1 + crates/whatsapp/src/plugin.rs | 1 + crates/whatsapp/src/sled_store.rs | 1 + crates/whatsapp/src/state.rs | 1 + 7 files changed, 7 insertions(+) diff --git a/crates/whatsapp/src/access.rs b/crates/whatsapp/src/access.rs index 15646c83..b6a5cee3 100644 --- a/crates/whatsapp/src/access.rs +++ b/crates/whatsapp/src/access.rs @@ -87,6 +87,7 @@ impl std::fmt::Display for AccessDenied { } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use super::*; diff --git a/crates/whatsapp/src/config.rs b/crates/whatsapp/src/config.rs index ffb1e26e..878df243 100644 --- a/crates/whatsapp/src/config.rs +++ b/crates/whatsapp/src/config.rs @@ -86,6 +86,7 @@ impl Default for WhatsAppAccountConfig { } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use super::*; diff --git a/crates/whatsapp/src/memory_store.rs b/crates/whatsapp/src/memory_store.rs index 93876189..cf5ddcd6 100644 --- a/crates/whatsapp/src/memory_store.rs +++ b/crates/whatsapp/src/memory_store.rs @@ -373,6 +373,7 @@ impl DeviceStore for MemoryStore { } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use super::*; diff --git a/crates/whatsapp/src/otp.rs b/crates/whatsapp/src/otp.rs index f53dca2f..2f27ff4c 100644 --- a/crates/whatsapp/src/otp.rs +++ b/crates/whatsapp/src/otp.rs @@ -227,6 +227,7 @@ fn generate_otp_code() -> String { pub const OTP_CHALLENGE_MSG: &str = "To use this bot, please enter the verification code.\n\nAsk the bot owner for the code \u{2014} it is visible in the web UI under Channels \u{2192} Senders.\n\nThe code expires in 5 minutes."; #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use super::*; diff --git a/crates/whatsapp/src/plugin.rs b/crates/whatsapp/src/plugin.rs index 50818a1d..1e31d8d2 100644 --- a/crates/whatsapp/src/plugin.rs +++ b/crates/whatsapp/src/plugin.rs @@ -231,6 +231,7 @@ impl ChannelStatus for WhatsAppPlugin { } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use super::*; diff --git a/crates/whatsapp/src/sled_store.rs b/crates/whatsapp/src/sled_store.rs index 2c93159e..bd869ab0 100644 --- a/crates/whatsapp/src/sled_store.rs +++ b/crates/whatsapp/src/sled_store.rs @@ -501,6 +501,7 @@ impl DeviceStore for SledStore { } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use super::*; diff --git a/crates/whatsapp/src/state.rs b/crates/whatsapp/src/state.rs index 1f8aba90..081f5e55 100644 --- a/crates/whatsapp/src/state.rs +++ b/crates/whatsapp/src/state.rs @@ -103,6 +103,7 @@ pub(crate) fn has_bot_watermark(text: &str) -> bool { } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use super::*;