diff --git a/CHANGELOG.md b/CHANGELOG.md index cbbe6eb8..0a70edd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -174,6 +174,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `retry_after_seconds`. - **Login retry UX**: The login page now disables the password Sign In button while throttled and shows a live `Retry in Xs` countdown. +- **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. ### Changed @@ -201,7 +239,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.6.0] - 2026-02-10 ### Added - - **`BeforeLLMCall` / `AfterLLMCall` hooks**: New modifying hook events that fire before sending prompts to the LLM provider and after receiving responses (before tool execution). Enables prompt injection filtering, PII redaction, @@ -251,7 +288,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 setups (without a password) would incorrectly allow unauthenticated access on local connections, because the `has_password()` check returned false even though `is_setup_complete()` was true. - ## [0.5.0] - 2026-02-09 ### Added @@ -269,7 +305,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **API key scope enforcement**: API keys with empty/no scopes are now denied access instead of silently receiving full admin privileges. Keys must specify at least one scope explicitly (least-privilege by default). - ## [0.4.1] - 2026-02-09 ### Fixed @@ -349,7 +384,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **CodSpeed workflow zizmor audit**: Pinned `CodSpeedHQ/action@v4` to commit SHA to satisfy zizmor's `unpinned-uses` audit. - ## [0.3.7] - 2026-02-09 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 57bc88aa..aae73c68 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 @@ -938,7 +943,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: diff --git a/Cargo.lock b/Cargo.lock index 41d27e7f..8d1e3b04 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" @@ -144,6 +179,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" @@ -262,6 +303,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" @@ -274,6 +327,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" @@ -643,6 +707,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" @@ -696,6 +769,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" @@ -822,10 +904,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" @@ -1062,6 +1154,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" @@ -1199,6 +1309,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" @@ -1230,6 +1349,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" @@ -1346,7 +1492,7 @@ dependencies = [ "hashbrown 0.14.5", "lock_api", "once_cell", - "parking_lot_core", + "parking_lot_core 0.9.12", ] [[package]] @@ -1613,6 +1759,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" @@ -1747,12 +1902,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" @@ -1807,6 +1981,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" @@ -1873,6 +2057,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" @@ -1908,6 +2098,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" @@ -1916,6 +2112,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs 0.6.0", ] [[package]] @@ -1981,6 +2178,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" @@ -2046,7 +2253,7 @@ checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" dependencies = [ "futures-core", "lock_api", - "parking_lot", + "parking_lot 0.12.5", ] [[package]] @@ -2130,6 +2337,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" @@ -2190,6 +2406,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" @@ -2244,7 +2470,7 @@ dependencies = [ "gix-worktree", "gix-worktree-state", "gix-worktree-stream", - "parking_lot", + "parking_lot 0.12.5", "regex", "signal-hook", "smallvec", @@ -2506,11 +2732,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]] @@ -2580,7 +2806,7 @@ checksum = "52f1eecdd006390cbed81f105417dbf82a6fe40842022006550f2e32484101da" dependencies = [ "gix-hash", "hashbrown 0.16.1", - "parking_lot", + "parking_lot 0.12.5", ] [[package]] @@ -2699,7 +2925,7 @@ dependencies = [ "gix-pack", "gix-path", "gix-quote", - "parking_lot", + "parking_lot 0.12.5", "tempfile", "thiserror 2.0.18", ] @@ -2771,7 +2997,7 @@ checksum = "6d48536da48fa4ae9d99bf46479f37a19a58427711e1927c80790856d4a490f6" dependencies = [ "gix-command", "gix-config-value", - "parking_lot", + "parking_lot 0.12.5", "rustix", "thiserror 2.0.18", ] @@ -2948,7 +3174,7 @@ dependencies = [ "dashmap", "gix-fs", "libc", - "parking_lot", + "parking_lot 0.12.5", "signal-hook", "signal-hook-registry", "tempfile", @@ -3075,7 +3301,7 @@ dependencies = [ "gix-object", "gix-path", "gix-traverse", - "parking_lot", + "parking_lot 0.12.5", "thiserror 2.0.18", ] @@ -3716,6 +3942,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" @@ -3782,7 +4018,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", @@ -4075,6 +4311,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" @@ -4166,6 +4408,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" @@ -4343,6 +4591,26 @@ dependencies = [ "tokio", ] +[[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.8.8" @@ -4576,10 +4844,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", @@ -4911,6 +5181,34 @@ dependencies = [ "wiremock", ] +[[package]] +name = "moltis-whatsapp" +version = "0.8.8" +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" @@ -4921,6 +5219,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" @@ -5129,6 +5433,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" @@ -5245,6 +5555,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" @@ -5252,7 +5573,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]] @@ -5291,6 +5626,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" @@ -5337,25 +5682,74 @@ 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_shared" +name = "phf" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" dependencies = [ - "siphasher", + "phf_shared 0.12.1", ] [[package]] -name = "pin-project" +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" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" @@ -5464,6 +5858,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" @@ -5572,7 +5978,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]] @@ -5602,6 +6077,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" @@ -5803,6 +6287,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" @@ -6402,6 +6895,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" @@ -6642,6 +7144,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 = "similar" version = "2.7.0" @@ -6672,13 +7180,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", ] @@ -7095,6 +7619,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" @@ -7323,7 +7853,7 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", + "parking_lot 0.12.5", "pin-project-lite", "signal-hook-registry", "socket2 0.6.2", @@ -7421,6 +7951,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" @@ -7736,6 +8287,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" @@ -7748,6 +8309,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" @@ -7826,6 +8418,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" @@ -7851,6 +8547,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" @@ -8085,6 +8792,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" @@ -8664,6 +9447,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" @@ -8769,6 +9564,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" @@ -8809,6 +9618,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 3a744b44..1d54a35c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ members = [ "crates/telegram", "crates/tools", "crates/voice", + "crates/whatsapp", ] resolver = "2" @@ -102,8 +103,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" @@ -172,6 +184,7 @@ 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" } [profile.release] lto = "thin" diff --git a/README.md b/README.md index 3124737e..9c2824ab 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,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 @@ -221,7 +221,7 @@ cloud relay required. ``` ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ Web UI │ │ Telegram │ │ Discord │ +│ Web UI │ │ Telegram │ │ WhatsApp │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ └────────┬───────┴────────┬───────┘ @@ -491,6 +491,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/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/channels/src/plugin.rs b/crates/channels/src/plugin.rs index f0a85951..93c38a20 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. @@ -346,6 +376,7 @@ pub trait ChannelStreamOutbound: Send + Sync { } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use super::*; @@ -398,6 +429,66 @@ 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"); + } + struct DummyOutbound; #[async_trait] diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index ce01e014..55416aa1 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -64,11 +64,12 @@ which = { 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 feeb74af..448048be 100644 --- a/crates/config/src/schema.rs +++ b/crates/config/src/schema.rs @@ -956,6 +956,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 92227e96..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([ @@ -309,7 +306,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/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/Cargo.toml b/crates/gateway/Cargo.toml index bae30d95..f0ce0a6f 100644 --- a/crates/gateway/Cargo.toml +++ b/crates/gateway/Cargo.toml @@ -44,9 +44,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 } @@ -89,6 +91,7 @@ default = [ "tls", "voice", "web-ui", + "whatsapp", ] file-watcher = ["moltis-memory/file-watcher", "moltis-skills/file-watcher"] local-embeddings = ["moltis-memory/local-embeddings"] @@ -110,6 +113,7 @@ tls = [ ] voice = ["dep:moltis-voice"] web-ui = ["dep:askama", "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 b155a848..a4845c5e 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,11 +58,29 @@ function loadSenders() { }); } -// ── Telegram icon (CSS mask-image) ────────────────────────── +// ── Channel icons (CSS mask-image / inline SVG) ───────────── function TelegramIcon() { return html``; } +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; @@ -69,7 +94,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); @@ -82,10 +107,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}`}
@@ -106,13 +131,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(() => { @@ -162,32 +209,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))} ` } @@ -243,8 +265,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(""); @@ -283,7 +342,7 @@ function AddChannelModal() { }).then((res) => { saving.value = false; if (res?.ok) { - showAddModal.value = false; + showAddTelegramModal.value = false; addModel.value = ""; allowlistItems.value = []; loadChannels(); @@ -303,8 +362,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">
@@ -349,6 +408,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; @@ -362,18 +559,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); @@ -388,7 +592,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."; } }); } @@ -403,31 +607,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} /> `; @@ -502,7 +748,9 @@ export function initChannels(container) { _channelsContainer = 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 e974f657..69f075dc 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-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,.theme-btn .icon{width:16px;height:16px}.logout-btn{background:var(--error,#e55);color:#fff;border-radius:var(--radius-sm);cursor:pointer;border:none;justify-content:center;align-items:center;width:32px;height:32px;transition:all .15s;display:flex}.logout-btn:hover{filter:brightness(1.15)}.logout-btn .icon{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-error-result{background:var(--error);align-items:center;gap:6px;margin-top:6px;padding:6px 10px;display:flex}@supports (color:color-mix(in lab, red, red)){.voice-error-result{background:color-mix(in srgb,var(--error)10%,transparent)}}.voice-error-result{border-radius:var(--radius-sm);border-left:3px solid var(--error);color:var(--error);font-size:.75rem}.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);border-left:3px solid #0000;padding:8px 12px;transition:background .15s;display:flex}.session-item:hover{background:var(--bg-hover)}.session-item.active{background:var(--accent-subtle);border-left-color:var(--accent)}.session-info{flex:1;min-width:0}.session-label{color:var(--text);white-space:nowrap;align-items:center;gap:8px;min-width:0;font-size:.82rem;display:flex}.session-label [data-label-text]{text-overflow:ellipsis;overflow:hidden}.session-preview{color:var(--muted);-webkit-line-clamp:2;white-space:normal;-webkit-box-orient:vertical;margin-top:2px;font-size:.7rem;line-height:1.4;display:-webkit-box;overflow:hidden}.session-meta{color:var(--muted);margin-top:2px;font-size:.7rem}.session-time{color:var(--muted);white-space:nowrap;flex-shrink:0;margin-left:auto;font-size:.7rem}.session-icon{flex-shrink:0;justify-content:center;align-items:center;width:16px;height:16px;margin-right:4px;display:inline-flex;position:relative}.session-badge{background:var(--muted);min-width:14px;height:14px;color:var(--bg);text-align:center;pointer-events:none;box-sizing:border-box;border-radius:7px;padding:0 3px;font-size:9px;font-weight:700;line-height:14px;position:absolute;top:-6px;right:-8px}.session-item.unread .session-badge{background:var(--accent)}.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,.session-item.replying .session-icon .icon{display:none}.session-item.replying .session-spinner{display:inline}.chat-session-name{color:var(--muted);font-size:.75rem}.chat-session-rename-input{color:var(--text);background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);outline:none;max-width:200px;padding:2px 6px;font-size:.75rem}.chat-session-rename-input:focus{border-color:var(--border-strong)}.chat-session-btn{color:var(--muted);border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;background:0 0;padding:2px 6px;font-size:.7rem;transition:color .15s,border-color .15s}.chat-session-btn:hover{color:var(--text);border-color:var(--border-strong)}.chat-session-btn-danger{color:#fff;background:var(--error);border-color:var(--error)}.chat-session-btn-danger:hover{color:#fff;opacity:.85}.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)}.model-card.selected{border-color:var(--accent);background:var(--accent-subtle)}.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}.drag-over{outline:2px dashed var(--accent);outline-offset:-2px;background:var(--accent-subtle)}.media-preview-strip{background:var(--surface);border-top:1px solid var(--border);flex-wrap:wrap;gap:8px;padding:8px 16px;display:flex;overflow-x:auto}.media-preview-strip.hidden{display:none}.media-preview-item{background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);align-items:center;gap:6px;max-width:200px;padding:4px 8px;display:flex}.media-preview-thumb{object-fit:cover;border-radius:4px;flex-shrink:0;width:32px;height:32px}.media-preview-name{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:.72rem;overflow:hidden}.media-preview-remove{color:var(--muted);cursor:pointer;background:0 0;border:none;border-radius:3px;flex-shrink:0;padding:2px 4px;font-size:.75rem;transition:color .15s}.media-preview-remove:hover{color:var(--error)}.msg-image-row{flex-wrap:wrap;gap:6px;margin-top:8px;display:flex}.msg-image-thumb{border-radius:var(--radius-sm);border:1px solid var(--border);object-fit:cover;max-width:120px;max-height:90px}.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)}.icon{vertical-align:middle;background-color:currentColor;flex-shrink:0;width:16px;height:16px;display:inline-block;-webkit-mask-position:50%;mask-position:50%;-webkit-mask-size:contain;mask-size:contain;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.icon-xs{width:10px;height:10px}.icon-sm{width:12px;height:12px}.icon-md{width:14px;height:14px}.icon-lg{width:20px;height:20px}.icon-xl{width:24px;height:24px}.icon-chat{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 0 1 1.037-.443 48.282 48.282 0 0 0 5.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 0 1 1.037-.443 48.282 48.282 0 0 0 5.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z'/%3E%3C/svg%3E")}.icon-telegram{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' d='M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' d='M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z'/%3E%3C/svg%3E")}.icon-cron{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z'/%3E%3C/svg%3E")}.icon-branch{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M4 3v18M20 3v6c0 2-2 3-4 3H4'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M4 3v18M20 3v6c0 2-2 3-4 3H4'/%3E%3C/svg%3E")}.icon-folder{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z'/%3E%3C/svg%3E")}.icon-channels{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M5.25 8.25h15m-16.5 7.5h15m-1.8-13.5-3.9 19.5m-2.1-19.5-3.9 19.5'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M5.25 8.25h15m-16.5 7.5h15m-1.8-13.5-3.9 19.5m-2.1-19.5-3.9 19.5'/%3E%3C/svg%3E")}.icon-cube{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9'/%3E%3C/svg%3E")}.icon-document{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z'/%3E%3C/svg%3E")}.icon-link{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244'/%3E%3C/svg%3E")}.icon-chart-bar{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z'/%3E%3C/svg%3E")}.icon-server{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M5.25 14.25H18.75M5.25 14.25C3.59315 14.25 2.25 12.9069 2.25 11.25M5.25 14.25C3.59315 14.25 2.25 15.5931 2.25 17.25C2.25 18.9069 3.59315 20.25 5.25 20.25H18.75C20.4069 20.25 21.75 18.9069 21.75 17.25C21.75 15.5931 20.4069 14.25 18.75 14.25M2.25 11.25C2.25 9.59315 3.59315 8.25 5.25 8.25H18.75C20.4069 8.25 21.75 9.59315 21.75 11.25M2.25 11.25C2.25 10.2763 2.5658 9.32893 3.15 8.55L5.7375 5.1C6.37488 4.25016 7.37519 3.75 8.4375 3.75H15.5625C16.6248 3.75 17.6251 4.25016 18.2625 5.1L20.85 8.55C21.4342 9.32893 21.75 10.2763 21.75 11.25M21.75 11.25C21.75 12.9069 20.4069 14.25 18.75 14.25M18.75 17.25H18.7575V17.2575H18.75V17.25ZM18.75 11.25H18.7575V11.2575H18.75V11.25ZM15.75 17.25H15.7575V17.2575H15.75V17.25ZM15.75 11.25H15.7575V11.2575H15.75V11.25Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M5.25 14.25H18.75M5.25 14.25C3.59315 14.25 2.25 12.9069 2.25 11.25M5.25 14.25C3.59315 14.25 2.25 15.5931 2.25 17.25C2.25 18.9069 3.59315 20.25 5.25 20.25H18.75C20.4069 20.25 21.75 18.9069 21.75 17.25C21.75 15.5931 20.4069 14.25 18.75 14.25M2.25 11.25C2.25 9.59315 3.59315 8.25 5.25 8.25H18.75C20.4069 8.25 21.75 9.59315 21.75 11.25M2.25 11.25C2.25 10.2763 2.5658 9.32893 3.15 8.55L5.7375 5.1C6.37488 4.25016 7.37519 3.75 8.4375 3.75H15.5625C16.6248 3.75 17.6251 4.25016 18.2625 5.1L20.85 8.55C21.4342 9.32893 21.75 10.2763 21.75 11.25M21.75 11.25C21.75 12.9069 20.4069 14.25 18.75 14.25M18.75 17.25H18.7575V17.2575H18.75V17.25ZM18.75 11.25H18.7575V11.2575H18.75V11.25ZM15.75 17.25H15.7575V17.2575H15.75V17.25ZM15.75 11.25H15.7575V11.2575H15.75V11.25Z'/%3E%3C/svg%3E")}.icon-sparkles{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 0 0-2.455 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 0 0-2.455 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z'/%3E%3C/svg%3E")}.icon-wrench{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M11.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 0 0 4.486-6.336l-3.276 3.277a3.004 3.004 0 0 1-2.25-2.25l3.276-3.276a4.5 4.5 0 0 0-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M11.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 0 0 4.486-6.336l-3.276 3.277a3.004 3.004 0 0 1-2.25-2.25l3.276-3.276a4.5 4.5 0 0 0-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085'/%3E%3C/svg%3E")}.icon-settings{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.248a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z'/%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.248a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z'/%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z'/%3E%3C/svg%3E")}.icon-sun{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='5' stroke='black' stroke-width='1.5'/%3E%3Cpath stroke='black' stroke-width='1.5' d='M12 1v2m0 18v2M4.22 4.22l1.42 1.42m12.72 12.72 1.42 1.42M1 12h2m18 0h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='5' stroke='black' stroke-width='1.5'/%3E%3Cpath stroke='black' stroke-width='1.5' d='M12 1v2m0 18v2M4.22 4.22l1.42 1.42m12.72 12.72 1.42 1.42M1 12h2m18 0h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42'/%3E%3C/svg%3E")}.icon-moon{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' d='M21.752 15.002A9.72 9.72 0 0 1 18 15.75 9.75 9.75 0 0 1 8.25 6c0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25 9.75 9.75 0 0 0 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' d='M21.752 15.002A9.72 9.72 0 0 1 18 15.75 9.75 9.75 0 0 1 8.25 6c0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25 9.75 9.75 0 0 0 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z'/%3E%3C/svg%3E")}.icon-monitor{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Crect x='2' y='3' width='20' height='14' rx='2' stroke='black' stroke-width='1.5'/%3E%3Cpath stroke='black' stroke-width='1.5' d='M8 21h8m-4-4v4'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Crect x='2' y='3' width='20' height='14' rx='2' stroke='black' stroke-width='1.5'/%3E%3Cpath stroke='black' stroke-width='1.5' d='M8 21h8m-4-4v4'/%3E%3C/svg%3E")}.icon-logout{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='black'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='black'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9'/%3E%3C/svg%3E")}.icon-burger{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' d='M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' d='M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5'/%3E%3C/svg%3E")}.icon-chevron-down{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2' d='M19.5 8.25l-7.5 7.5-7.5-7.5'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2' d='M19.5 8.25l-7.5 7.5-7.5-7.5'/%3E%3C/svg%3E")}.icon-chevron-right{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='black' stroke-width='1.5' d='M6 4l4 4-4 4'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='black' stroke-width='1.5' d='M6 4l4 4-4 4'/%3E%3C/svg%3E")}.icon-lock{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' d='M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' d='M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z'/%3E%3C/svg%3E")}.icon-code{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5'/%3E%3C/svg%3E")}.icon-microphone{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z'/%3E%3C/svg%3E")}.icon-checkmark{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='3' d='M5 13l4 4L19 7'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='3' d='M5 13l4 4L19 7'/%3E%3C/svg%3E")}.icon-check-circle{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' d='M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' d='M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z'/%3E%3C/svg%3E")}.icon-warn-triangle{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' d='M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' d='M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z'/%3E%3C/svg%3E")}.icon-x-circle{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' d='m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' d='m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z'/%3E%3C/svg%3E")}.icon-info-circle{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' d='m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' d='m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z'/%3E%3C/svg%3E")}.icon-play{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='black' d='M8 5v14l11-7z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='black' d='M8 5v14l11-7z'/%3E%3C/svg%3E")}.icon-pause{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect fill='black' x='6' y='4' width='4' height='16' rx='1'/%3E%3Crect fill='black' x='14' y='4' width='4' height='16' rx='1'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect fill='black' x='6' y='4' width='4' height='16' rx='1'/%3E%3Crect fill='black' x='14' y='4' width='4' height='16' rx='1'/%3E%3C/svg%3E")}.icon-person{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z'/%3E%3C/svg%3E")}.icon-database{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125'/%3E%3C/svg%3E")}.icon-terminal{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='m6.75 7.5 3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='m6.75 7.5 3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25Z'/%3E%3C/svg%3E")}.icon-globe{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5a17.92 17.92 0 0 1-8.716-2.247m0 0A8.966 8.966 0 0 1 3 12c0-1.264.26-2.466.73-3.558'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5a17.92 17.92 0 0 1-8.716-2.247m0 0A8.966 8.966 0 0 1 3 12c0-1.264.26-2.466.73-3.558'/%3E%3C/svg%3E")}.icon-bell{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0'/%3E%3C/svg%3E")}.icon-heart{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z'/%3E%3C/svg%3E")}.icon-activity{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M3.75 3v11.25A2.25 2.25 0 0 0 6 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0 1 18 16.5h-2.25m-7.5 0h7.5m-7.5 0-1 3m8.5-3 1 3m0 0 .5 1.5m-.5-1.5h-9.5m0 0-.5 1.5M9 11.25v1.5M12 9v3.75m3-6v6'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M3.75 3v11.25A2.25 2.25 0 0 0 6 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0 1 18 16.5h-2.25m-7.5 0h7.5m-7.5 0-1 3m8.5-3 1 3m0 0 .5 1.5m-.5-1.5h-9.5m0 0-.5 1.5M9 11.25v1.5M12 9v3.75m3-6v6'/%3E%3C/svg%3E")}.icon-layers{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M12 2L2 7l10 5 10-5-10-5z'/%3E%3Cpath stroke='black' stroke-width='2' d='M2 17l10 5 10-5'/%3E%3Cpath stroke='black' stroke-width='2' d='M2 12l10 5 10-5'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M12 2L2 7l10 5 10-5-10-5z'/%3E%3Cpath stroke='black' stroke-width='2' d='M2 17l10 5 10-5'/%3E%3Cpath stroke='black' stroke-width='2' d='M2 12l10 5 10-5'/%3E%3C/svg%3E")}.icon-search{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Ccircle cx='11' cy='11' r='8' stroke='black' stroke-width='2'/%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M21 21l-4.35-4.35'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Ccircle cx='11' cy='11' r='8' stroke='black' stroke-width='2'/%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M21 21l-4.35-4.35'/%3E%3C/svg%3E")}.icon-chat-dots{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z'/%3E%3C/svg%3E")}.icon-chat-bubble{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'/%3E%3C/svg%3E")}.icon-terminal-cmd{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M4 17l6-6-6-6'/%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' d='M12 19h8'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M4 17l6-6-6-6'/%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' d='M12 19h8'/%3E%3C/svg%3E")}.icon-file{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z'/%3E%3Cpath stroke='black' stroke-width='2' d='M14 2v6h6'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z'/%3E%3Cpath stroke='black' stroke-width='2' d='M14 2v6h6'/%3E%3C/svg%3E")}.icon-warn-triangle-light{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z'/%3E%3Cline stroke='black' stroke-width='2' x1='12' y1='9' x2='12' y2='13'/%3E%3Cline stroke='black' stroke-width='2' x1='12' y1='17' x2='12.01' y2='17'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z'/%3E%3Cline stroke='black' stroke-width='2' x1='12' y1='9' x2='12' y2='13'/%3E%3Cline stroke='black' stroke-width='2' x1='12' y1='17' x2='12.01' y2='17'/%3E%3C/svg%3E")}.icon-settings-gear{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='3' stroke='black' stroke-width='2'/%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='3' stroke='black' stroke-width='2'/%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z'/%3E%3C/svg%3E")}.icon-compress{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M4 14h6v6'/%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M20 10h-6V4'/%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' d='M14 10l7-7'/%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' d='M3 21l7-7'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M4 14h6v6'/%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M20 10h-6V4'/%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' d='M14 10l7-7'/%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' d='M3 21l7-7'/%3E%3C/svg%3E")}.icon-no-providers{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M16 18a4 4 0 0 0-8 0'/%3E%3Ccircle cx='12' cy='11' r='3' stroke='black' stroke-width='1.5'/%3E%3Cpath stroke='black' stroke-width='1.5' d='M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2z'/%3E%3Cpath stroke='black' stroke-width='1.5' d='M2 12h2M20 12h2M12 2v2M12 20v2'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M16 18a4 4 0 0 0-8 0'/%3E%3Ccircle cx='12' cy='11' r='3' stroke='black' stroke-width='1.5'/%3E%3Cpath stroke='black' stroke-width='1.5' d='M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2z'/%3E%3Cpath stroke='black' stroke-width='1.5' d='M2 12h2M20 12h2M12 2v2M12 20v2'/%3E%3C/svg%3E")}.icon-share{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M9 8.25H7.5a2.25 2.25 0 0 0-2.25 2.25v9a2.25 2.25 0 0 0 2.25 2.25h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25H15m0-3-3-3m0 0-3 3m3-3v12'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M9 8.25H7.5a2.25 2.25 0 0 0-2.25 2.25v9a2.25 2.25 0 0 0 2.25 2.25h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25H15m0-3-3-3m0 0-3 3m3-3v12'/%3E%3C/svg%3E")}.icon-menu-dots{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='5' r='1' fill='black'/%3E%3Ccircle cx='12' cy='12' r='1' fill='black'/%3E%3Ccircle cx='12' cy='19' r='1' fill='black'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='5' r='1' fill='black'/%3E%3Ccircle cx='12' cy='12' r='1' fill='black'/%3E%3Ccircle cx='12' cy='19' r='1' fill='black'/%3E%3C/svg%3E")}}@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}.right-0{right:calc(var(--spacing)*0)}.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)}.-mr-4{margin-right:calc(var(--spacing)*-4)}.-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-full{height:100%}.max-h-48{max-height:calc(var(--spacing)*48)}.max-h-56{max-height:calc(var(--spacing)*56)}.max-h-80{max-height:calc(var(--spacing)*80)}.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-\[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-\[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}.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-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\(--ok\)\]{border-color:var(--ok)}.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-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)}.pr-4{padding-right: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\.62rem\]{font-size:.62rem}.text-\[0\.65rem\]{font-size:.65rem}.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-\[var\(--warning\)\]{color:var(--warning)}.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\:border-\[var\(--error\,\#e55\)\]:hover{border-color:var(--error,#e55)}.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\(--error\,\#e55\)\]:hover{color:var(--error,#e55)}.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-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-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,.theme-btn .icon{width:16px;height:16px}.logout-btn{background:var(--error,#e55);color:#fff;border-radius:var(--radius-sm);cursor:pointer;border:none;justify-content:center;align-items:center;width:32px;height:32px;transition:all .15s;display:flex}.logout-btn:hover{filter:brightness(1.15)}.logout-btn .icon{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-error-result{background:var(--error);align-items:center;gap:6px;margin-top:6px;padding:6px 10px;display:flex}@supports (color:color-mix(in lab, red, red)){.voice-error-result{background:color-mix(in srgb,var(--error)10%,transparent)}}.voice-error-result{border-radius:var(--radius-sm);border-left:3px solid var(--error);color:var(--error);font-size:.75rem}.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);border-left:3px solid #0000;padding:8px 12px;transition:background .15s;display:flex}.session-item:hover{background:var(--bg-hover)}.session-item.active{background:var(--accent-subtle);border-left-color:var(--accent)}.session-info{flex:1;min-width:0}.session-label{color:var(--text);white-space:nowrap;align-items:center;gap:8px;min-width:0;font-size:.82rem;display:flex}.session-label [data-label-text]{text-overflow:ellipsis;overflow:hidden}.session-preview{color:var(--muted);-webkit-line-clamp:2;white-space:normal;-webkit-box-orient:vertical;margin-top:2px;font-size:.7rem;line-height:1.4;display:-webkit-box;overflow:hidden}.session-meta{color:var(--muted);margin-top:2px;font-size:.7rem}.session-time{color:var(--muted);white-space:nowrap;flex-shrink:0;margin-left:auto;font-size:.7rem}.session-icon{flex-shrink:0;justify-content:center;align-items:center;width:16px;height:16px;margin-right:4px;display:inline-flex;position:relative}.session-badge{background:var(--muted);min-width:14px;height:14px;color:var(--bg);text-align:center;pointer-events:none;box-sizing:border-box;border-radius:7px;padding:0 3px;font-size:9px;font-weight:700;line-height:14px;position:absolute;top:-6px;right:-8px}.session-item.unread .session-badge{background:var(--accent)}.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,.session-item.replying .session-icon .icon{display:none}.session-item.replying .session-spinner{display:inline}.chat-session-name{color:var(--muted);font-size:.75rem}.chat-session-rename-input{color:var(--text);background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);outline:none;max-width:200px;padding:2px 6px;font-size:.75rem}.chat-session-rename-input:focus{border-color:var(--border-strong)}.chat-session-btn{color:var(--muted);border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;background:0 0;padding:2px 6px;font-size:.7rem;transition:color .15s,border-color .15s}.chat-session-btn:hover{color:var(--text);border-color:var(--border-strong)}.chat-session-btn-danger{color:#fff;background:var(--error);border-color:var(--error)}.chat-session-btn-danger:hover{color:#fff;opacity:.85}.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)}.model-card.selected{border-color:var(--accent);background:var(--accent-subtle)}.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}.drag-over{outline:2px dashed var(--accent);outline-offset:-2px;background:var(--accent-subtle)}.media-preview-strip{background:var(--surface);border-top:1px solid var(--border);flex-wrap:wrap;gap:8px;padding:8px 16px;display:flex;overflow-x:auto}.media-preview-strip.hidden{display:none}.media-preview-item{background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);align-items:center;gap:6px;max-width:200px;padding:4px 8px;display:flex}.media-preview-thumb{object-fit:cover;border-radius:4px;flex-shrink:0;width:32px;height:32px}.media-preview-name{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:.72rem;overflow:hidden}.media-preview-remove{color:var(--muted);cursor:pointer;background:0 0;border:none;border-radius:3px;flex-shrink:0;padding:2px 4px;font-size:.75rem;transition:color .15s}.media-preview-remove:hover{color:var(--error)}.msg-image-row{flex-wrap:wrap;gap:6px;margin-top:8px;display:flex}.msg-image-thumb{border-radius:var(--radius-sm);border:1px solid var(--border);object-fit:cover;max-width:120px;max-height:90px}.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)}.icon{vertical-align:middle;background-color:currentColor;flex-shrink:0;width:16px;height:16px;display:inline-block;-webkit-mask-position:50%;mask-position:50%;-webkit-mask-size:contain;mask-size:contain;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.icon-xs{width:10px;height:10px}.icon-sm{width:12px;height:12px}.icon-md{width:14px;height:14px}.icon-lg{width:20px;height:20px}.icon-xl{width:24px;height:24px}.icon-chat{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 0 1 1.037-.443 48.282 48.282 0 0 0 5.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 0 1 1.037-.443 48.282 48.282 0 0 0 5.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z'/%3E%3C/svg%3E")}.icon-telegram{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' d='M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' d='M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z'/%3E%3C/svg%3E")}.icon-cron{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z'/%3E%3C/svg%3E")}.icon-branch{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M4 3v18M20 3v6c0 2-2 3-4 3H4'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M4 3v18M20 3v6c0 2-2 3-4 3H4'/%3E%3C/svg%3E")}.icon-folder{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z'/%3E%3C/svg%3E")}.icon-channels{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M5.25 8.25h15m-16.5 7.5h15m-1.8-13.5-3.9 19.5m-2.1-19.5-3.9 19.5'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M5.25 8.25h15m-16.5 7.5h15m-1.8-13.5-3.9 19.5m-2.1-19.5-3.9 19.5'/%3E%3C/svg%3E")}.icon-cube{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9'/%3E%3C/svg%3E")}.icon-document{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z'/%3E%3C/svg%3E")}.icon-link{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244'/%3E%3C/svg%3E")}.icon-chart-bar{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z'/%3E%3C/svg%3E")}.icon-server{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M5.25 14.25H18.75M5.25 14.25C3.59315 14.25 2.25 12.9069 2.25 11.25M5.25 14.25C3.59315 14.25 2.25 15.5931 2.25 17.25C2.25 18.9069 3.59315 20.25 5.25 20.25H18.75C20.4069 20.25 21.75 18.9069 21.75 17.25C21.75 15.5931 20.4069 14.25 18.75 14.25M2.25 11.25C2.25 9.59315 3.59315 8.25 5.25 8.25H18.75C20.4069 8.25 21.75 9.59315 21.75 11.25M2.25 11.25C2.25 10.2763 2.5658 9.32893 3.15 8.55L5.7375 5.1C6.37488 4.25016 7.37519 3.75 8.4375 3.75H15.5625C16.6248 3.75 17.6251 4.25016 18.2625 5.1L20.85 8.55C21.4342 9.32893 21.75 10.2763 21.75 11.25M21.75 11.25C21.75 12.9069 20.4069 14.25 18.75 14.25M18.75 17.25H18.7575V17.2575H18.75V17.25ZM18.75 11.25H18.7575V11.2575H18.75V11.25ZM15.75 17.25H15.7575V17.2575H15.75V17.25ZM15.75 11.25H15.7575V11.2575H15.75V11.25Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M5.25 14.25H18.75M5.25 14.25C3.59315 14.25 2.25 12.9069 2.25 11.25M5.25 14.25C3.59315 14.25 2.25 15.5931 2.25 17.25C2.25 18.9069 3.59315 20.25 5.25 20.25H18.75C20.4069 20.25 21.75 18.9069 21.75 17.25C21.75 15.5931 20.4069 14.25 18.75 14.25M2.25 11.25C2.25 9.59315 3.59315 8.25 5.25 8.25H18.75C20.4069 8.25 21.75 9.59315 21.75 11.25M2.25 11.25C2.25 10.2763 2.5658 9.32893 3.15 8.55L5.7375 5.1C6.37488 4.25016 7.37519 3.75 8.4375 3.75H15.5625C16.6248 3.75 17.6251 4.25016 18.2625 5.1L20.85 8.55C21.4342 9.32893 21.75 10.2763 21.75 11.25M21.75 11.25C21.75 12.9069 20.4069 14.25 18.75 14.25M18.75 17.25H18.7575V17.2575H18.75V17.25ZM18.75 11.25H18.7575V11.2575H18.75V11.25ZM15.75 17.25H15.7575V17.2575H15.75V17.25ZM15.75 11.25H15.7575V11.2575H15.75V11.25Z'/%3E%3C/svg%3E")}.icon-sparkles{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 0 0-2.455 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 0 0-2.455 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z'/%3E%3C/svg%3E")}.icon-wrench{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M11.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 0 0 4.486-6.336l-3.276 3.277a3.004 3.004 0 0 1-2.25-2.25l3.276-3.276a4.5 4.5 0 0 0-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M11.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 0 0 4.486-6.336l-3.276 3.277a3.004 3.004 0 0 1-2.25-2.25l3.276-3.276a4.5 4.5 0 0 0-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085'/%3E%3C/svg%3E")}.icon-settings{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.248a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z'/%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.248a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z'/%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z'/%3E%3C/svg%3E")}.icon-sun{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='5' stroke='black' stroke-width='1.5'/%3E%3Cpath stroke='black' stroke-width='1.5' d='M12 1v2m0 18v2M4.22 4.22l1.42 1.42m12.72 12.72 1.42 1.42M1 12h2m18 0h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='5' stroke='black' stroke-width='1.5'/%3E%3Cpath stroke='black' stroke-width='1.5' d='M12 1v2m0 18v2M4.22 4.22l1.42 1.42m12.72 12.72 1.42 1.42M1 12h2m18 0h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42'/%3E%3C/svg%3E")}.icon-moon{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' d='M21.752 15.002A9.72 9.72 0 0 1 18 15.75 9.75 9.75 0 0 1 8.25 6c0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25 9.75 9.75 0 0 0 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' d='M21.752 15.002A9.72 9.72 0 0 1 18 15.75 9.75 9.75 0 0 1 8.25 6c0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25 9.75 9.75 0 0 0 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z'/%3E%3C/svg%3E")}.icon-monitor{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Crect x='2' y='3' width='20' height='14' rx='2' stroke='black' stroke-width='1.5'/%3E%3Cpath stroke='black' stroke-width='1.5' d='M8 21h8m-4-4v4'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Crect x='2' y='3' width='20' height='14' rx='2' stroke='black' stroke-width='1.5'/%3E%3Cpath stroke='black' stroke-width='1.5' d='M8 21h8m-4-4v4'/%3E%3C/svg%3E")}.icon-logout{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='black'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='black'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9'/%3E%3C/svg%3E")}.icon-burger{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' d='M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' d='M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5'/%3E%3C/svg%3E")}.icon-chevron-down{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2' d='M19.5 8.25l-7.5 7.5-7.5-7.5'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2' d='M19.5 8.25l-7.5 7.5-7.5-7.5'/%3E%3C/svg%3E")}.icon-chevron-right{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='black' stroke-width='1.5' d='M6 4l4 4-4 4'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='black' stroke-width='1.5' d='M6 4l4 4-4 4'/%3E%3C/svg%3E")}.icon-lock{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' d='M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' d='M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z'/%3E%3C/svg%3E")}.icon-code{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5'/%3E%3C/svg%3E")}.icon-microphone{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z'/%3E%3C/svg%3E")}.icon-checkmark{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='3' d='M5 13l4 4L19 7'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='3' d='M5 13l4 4L19 7'/%3E%3C/svg%3E")}.icon-check-circle{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' d='M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' d='M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z'/%3E%3C/svg%3E")}.icon-warn-triangle{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' d='M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' d='M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z'/%3E%3C/svg%3E")}.icon-x-circle{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' d='m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' d='m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z'/%3E%3C/svg%3E")}.icon-info-circle{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' d='m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' d='m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z'/%3E%3C/svg%3E")}.icon-play{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='black' d='M8 5v14l11-7z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='black' d='M8 5v14l11-7z'/%3E%3C/svg%3E")}.icon-pause{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect fill='black' x='6' y='4' width='4' height='16' rx='1'/%3E%3Crect fill='black' x='14' y='4' width='4' height='16' rx='1'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect fill='black' x='6' y='4' width='4' height='16' rx='1'/%3E%3Crect fill='black' x='14' y='4' width='4' height='16' rx='1'/%3E%3C/svg%3E")}.icon-person{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z'/%3E%3C/svg%3E")}.icon-database{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125'/%3E%3C/svg%3E")}.icon-terminal{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='m6.75 7.5 3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='m6.75 7.5 3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25Z'/%3E%3C/svg%3E")}.icon-globe{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5a17.92 17.92 0 0 1-8.716-2.247m0 0A8.966 8.966 0 0 1 3 12c0-1.264.26-2.466.73-3.558'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5a17.92 17.92 0 0 1-8.716-2.247m0 0A8.966 8.966 0 0 1 3 12c0-1.264.26-2.466.73-3.558'/%3E%3C/svg%3E")}.icon-bell{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0'/%3E%3C/svg%3E")}.icon-heart{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z'/%3E%3C/svg%3E")}.icon-activity{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M3.75 3v11.25A2.25 2.25 0 0 0 6 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0 1 18 16.5h-2.25m-7.5 0h7.5m-7.5 0-1 3m8.5-3 1 3m0 0 .5 1.5m-.5-1.5h-9.5m0 0-.5 1.5M9 11.25v1.5M12 9v3.75m3-6v6'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M3.75 3v11.25A2.25 2.25 0 0 0 6 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0 1 18 16.5h-2.25m-7.5 0h7.5m-7.5 0-1 3m8.5-3 1 3m0 0 .5 1.5m-.5-1.5h-9.5m0 0-.5 1.5M9 11.25v1.5M12 9v3.75m3-6v6'/%3E%3C/svg%3E")}.icon-layers{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M12 2L2 7l10 5 10-5-10-5z'/%3E%3Cpath stroke='black' stroke-width='2' d='M2 17l10 5 10-5'/%3E%3Cpath stroke='black' stroke-width='2' d='M2 12l10 5 10-5'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M12 2L2 7l10 5 10-5-10-5z'/%3E%3Cpath stroke='black' stroke-width='2' d='M2 17l10 5 10-5'/%3E%3Cpath stroke='black' stroke-width='2' d='M2 12l10 5 10-5'/%3E%3C/svg%3E")}.icon-search{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Ccircle cx='11' cy='11' r='8' stroke='black' stroke-width='2'/%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M21 21l-4.35-4.35'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Ccircle cx='11' cy='11' r='8' stroke='black' stroke-width='2'/%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M21 21l-4.35-4.35'/%3E%3C/svg%3E")}.icon-chat-dots{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z'/%3E%3C/svg%3E")}.icon-chat-bubble{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'/%3E%3C/svg%3E")}.icon-terminal-cmd{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M4 17l6-6-6-6'/%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' d='M12 19h8'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M4 17l6-6-6-6'/%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' d='M12 19h8'/%3E%3C/svg%3E")}.icon-file{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z'/%3E%3Cpath stroke='black' stroke-width='2' d='M14 2v6h6'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z'/%3E%3Cpath stroke='black' stroke-width='2' d='M14 2v6h6'/%3E%3C/svg%3E")}.icon-warn-triangle-light{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z'/%3E%3Cline stroke='black' stroke-width='2' x1='12' y1='9' x2='12' y2='13'/%3E%3Cline stroke='black' stroke-width='2' x1='12' y1='17' x2='12.01' y2='17'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z'/%3E%3Cline stroke='black' stroke-width='2' x1='12' y1='9' x2='12' y2='13'/%3E%3Cline stroke='black' stroke-width='2' x1='12' y1='17' x2='12.01' y2='17'/%3E%3C/svg%3E")}.icon-settings-gear{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='3' stroke='black' stroke-width='2'/%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='3' stroke='black' stroke-width='2'/%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z'/%3E%3C/svg%3E")}.icon-compress{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M4 14h6v6'/%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M20 10h-6V4'/%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' d='M14 10l7-7'/%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' d='M3 21l7-7'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M4 14h6v6'/%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M20 10h-6V4'/%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' d='M14 10l7-7'/%3E%3Cpath stroke='black' stroke-width='2' stroke-linecap='round' d='M3 21l7-7'/%3E%3C/svg%3E")}.icon-no-providers{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M16 18a4 4 0 0 0-8 0'/%3E%3Ccircle cx='12' cy='11' r='3' stroke='black' stroke-width='1.5'/%3E%3Cpath stroke='black' stroke-width='1.5' d='M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2z'/%3E%3Cpath stroke='black' stroke-width='1.5' d='M2 12h2M20 12h2M12 2v2M12 20v2'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M16 18a4 4 0 0 0-8 0'/%3E%3Ccircle cx='12' cy='11' r='3' stroke='black' stroke-width='1.5'/%3E%3Cpath stroke='black' stroke-width='1.5' d='M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2z'/%3E%3Cpath stroke='black' stroke-width='1.5' d='M2 12h2M20 12h2M12 2v2M12 20v2'/%3E%3C/svg%3E")}.icon-share{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M9 8.25H7.5a2.25 2.25 0 0 0-2.25 2.25v9a2.25 2.25 0 0 0 2.25 2.25h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25H15m0-3-3-3m0 0-3 3m3-3v12'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M9 8.25H7.5a2.25 2.25 0 0 0-2.25 2.25v9a2.25 2.25 0 0 0 2.25 2.25h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25H15m0-3-3-3m0 0-3 3m3-3v12'/%3E%3C/svg%3E")}.icon-menu-dots{-webkit-mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='5' r='1' fill='black'/%3E%3Ccircle cx='12' cy='12' r='1' fill='black'/%3E%3Ccircle cx='12' cy='19' r='1' fill='black'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='5' r='1' fill='black'/%3E%3Ccircle cx='12' cy='12' r='1' fill='black'/%3E%3Ccircle cx='12' cy='19' r='1' fill='black'/%3E%3C/svg%3E")}}@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}.right-0{right:calc(var(--spacing)*0)}.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)}.-mr-4{margin-right:calc(var(--spacing)*-4)}.-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-full{height:100%}.max-h-48{max-height:calc(var(--spacing)*48)}.max-h-56{max-height:calc(var(--spacing)*56)}.max-h-80{max-height:calc(var(--spacing)*80)}.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-\[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-\[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}.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-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\(--ok\)\]{border-color:var(--ok)}.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)}.pr-4{padding-right: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\.62rem\]{font-size:.62rem}.text-\[0\.65rem\]{font-size:.65rem}.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-\[var\(--warning\)\]{color:var(--warning)}.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\:border-\[var\(--error\,\#e55\)\]:hover{border-color:var(--error,#e55)}.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\(--error\,\#e55\)\]:hover{color:var(--error,#e55)}.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-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/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.rs b/crates/gateway/src/channel.rs index 87c42732..c3e1b039 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) @@ -339,13 +586,12 @@ impl ChannelService for LiveChannelService { obj.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, @@ -355,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"); @@ -377,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) @@ -394,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, @@ -410,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 537f8fd4..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 @@ -149,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 @@ -346,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"); @@ -556,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 @@ -588,7 +571,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 @@ -750,7 +736,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 8cd7ba6e..a5d86679 100644 --- a/crates/gateway/src/chat.rs +++ b/crates/gateway/src/chat.rs @@ -4633,6 +4633,44 @@ async fn deliver_channel_replies_to_targets( } }, }, + 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 => { + let result = if logbook_html.is_empty() { + outbound + .send_text(&target.account_id, &target.chat_id, &text, reply_to) + .await + } else { + outbound + .send_text_with_suffix( + &target.account_id, + &target.chat_id, + &text, + &logbook_html, + reply_to, + ) + .await + }; + if let Err(e) = result { + warn!( + account_id = target.account_id, + chat_id = target.chat_id, + "failed to send channel reply: {e}" + ); + } + }, + }, } })); } @@ -4928,7 +4966,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) @@ -4939,8 +4977,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; @@ -4948,7 +4985,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" ); } }, @@ -5847,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(); @@ -5896,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 f81bc95d..a75bb0c7 100644 --- a/crates/gateway/src/server.rs +++ b/crates/gateway/src/server.rs @@ -32,7 +32,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; @@ -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(); @@ -1708,9 +1703,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()), @@ -1719,11 +1714,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 { @@ -1737,7 +1742,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()); @@ -1754,13 +1792,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" + ); + }, } } }, @@ -1770,19 +1829,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)); @@ -2874,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; } }, @@ -2945,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/gateway/ui/e2e/start-gateway-onboarding.sh b/crates/gateway/ui/e2e/start-gateway-onboarding.sh index 26ddb5a7..33acc10b 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 28243919..237675f1 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/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, + }); } } 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..b6a5cee3 --- /dev/null +++ b/crates/whatsapp/src/access.rs @@ -0,0 +1,231 @@ +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)] +#[allow(clippy::unwrap_used)] +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..878df243 --- /dev/null +++ b/crates/whatsapp/src/config.rs @@ -0,0 +1,159 @@ +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)] +#[allow(clippy::unwrap_used)] +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..35c677ad --- /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_or_else(|e| e.into_inner()); + 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..32731906 --- /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_or_else(|e| e.into_inner()); + accts + .get(account_id) + .map(|s| { + let otp = s.otp.lock().unwrap_or_else(|e| e.into_inner()); + 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_or_else(|e| e.into_inner()); + match accts.get(account_id) { + Some(s) => { + let mut otp = s.otp.lock().unwrap_or_else(|e| e.into_inner()); + 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_or_else(|e| e.into_inner()); + match accts.get(account_id) { + Some(s) => { + let mut otp = s.otp.lock().unwrap_or_else(|e| e.into_inner()); + 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..cf5ddcd6 --- /dev/null +++ b/crates/whatsapp/src/memory_store.rs @@ -0,0 +1,571 @@ +//! 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)] +#[allow(clippy::unwrap_used)] +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..2f27ff4c --- /dev/null +++ b/crates/whatsapp/src/otp.rs @@ -0,0 +1,372 @@ +//! 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)] +#[allow(clippy::unwrap_used)] +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..c9619a45 --- /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_or_else(|e| e.into_inner()); + 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_or_else(|e| e.into_inner()); + 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..1e31d8d2 --- /dev/null +++ b/crates/whatsapp/src/plugin.rs @@ -0,0 +1,262 @@ +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_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_or_else(|e| e.into_inner()); + 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_or_else(|e| e.into_inner()); + 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_or_else(|e| e.into_inner()); + 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_or_else(|e| e.into_inner()); + accounts + .get(account_id) + .map(|s| { + let otp = s.otp.lock().unwrap_or_else(|e| e.into_inner()); + 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_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_or_else(|e| e.into_inner()); + 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_or_else(|e| e.into_inner()); + 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)] +#[allow(clippy::unwrap_used)] +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..bd869ab0 --- /dev/null +++ b/crates/whatsapp/src/sled_store.rs @@ -0,0 +1,742 @@ +//! 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)] +#[allow(clippy::unwrap_used)] +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..081f5e55 --- /dev/null +++ b/crates/whatsapp/src/state.rs @@ -0,0 +1,207 @@ +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_or_else(|e| e.into_inner()); + 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_or_else(|e| e.into_inner()); + 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)] +#[allow(clippy::unwrap_used)] +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 74063b4d..0c7883a0 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -23,6 +23,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) +``` diff --git a/scripts/local-validate.sh b/scripts/local-validate.sh index e62ccfe9..80c9c524 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" @@ -146,7 +147,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 +${nightly_toolchain} clippy -Z unstable-options --workspace --all-features --all-targets --timings -- -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 && 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 @@ -154,10 +155,10 @@ if [[ "$(uname -s)" == "Darwin" ]] && ! command -v nvcc >/dev/null 2>&1; then lint_cmd="cargo +${nightly_toolchain} clippy -Z unstable-options --workspace --all-targets --timings -- -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 defaults (no --all-features)." >&2 echo "Override with LOCAL_VALIDATE_LINT_CMD / LOCAL_VALIDATE_TEST_CMD / LOCAL_VALIDATE_COVERAGE_CMD if needed." >&2 @@ -205,6 +206,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" @@ -214,6 +245,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" \ @@ -231,7 +266,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 } @@ -375,6 +412,7 @@ run_check "local/test" "$test_cmd" # Gateway web UI e2e tests. if [[ "${LOCAL_VALIDATE_SKIP_E2E:-0}" != "1" ]]; then + cleanup_e2e_ports run_check "local/e2e" "$e2e_cmd" else echo "Skipping E2E checks (LOCAL_VALIDATE_SKIP_E2E=1)."