diff --git a/Cargo.lock b/Cargo.lock index a93253e..ef2c55c 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 0.1.7", + "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 0.2.17", +] + +[[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 0.5.1", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -32,6 +67,12 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -100,6 +141,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-trait" version = "0.1.89" @@ -232,17 +284,44 @@ 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 = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "bytes" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +dependencies = [ + "serde", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] [[package]] name = "cc" @@ -293,6 +372,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + [[package]] name = "cmake" version = "0.1.58" @@ -302,6 +391,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "compression-codecs" version = "0.4.38" @@ -327,12 +422,47 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -376,6 +506,60 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "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", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -400,6 +584,33 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -407,7 +618,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -443,6 +654,15 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dunce" version = "1.0.5" @@ -477,6 +697,26 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "env_filter", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -538,12 +778,24 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[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" @@ -552,6 +804,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -560,6 +813,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -718,6 +977,25 @@ 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 0.6.2", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval 0.7.1", +] + [[package]] name = "h2" version = "0.4.15" @@ -737,13 +1015,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -752,7 +1039,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -776,6 +1063,12 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.2" @@ -788,6 +1081,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -842,6 +1144,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.10.1" @@ -1049,6 +1360,16 @@ dependencies = [ "hashbrown 0.17.1", ] +[[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 = "ipnet" version = "2.12.0" @@ -1158,6 +1479,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -1194,6 +1521,12 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[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.2" @@ -1206,6 +1539,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1233,6 +1576,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "nom" version = "7.1.3" @@ -1252,6 +1601,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -1277,6 +1632,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "option-ext" version = "0.2.0" @@ -1312,6 +1673,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1319,33 +1690,82 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] -name = "pin-project" -version = "1.1.13" +name = "petgraph" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ - "pin-project-internal", + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", ] [[package]] -name = "pin-project-internal" -version = "1.1.13" +name = "phf" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "proc-macro2", - "quote", - "syn", + "phf_shared", ] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "phf_codegen" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] [[package]] -name = "pin-utils" +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[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.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" @@ -1356,6 +1776,29 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede" +dependencies = [ + "cpubits", + "cpufeatures 0.3.0", + "universal-hash 0.6.1", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1365,6 +1808,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1393,6 +1842,23 @@ dependencies = [ "prost-derive", ] +[[package]] +name = "prost-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prost", + "prost-types", + "regex", + "tempfile", +] + [[package]] name = "prost-derive" version = "0.14.4" @@ -1406,6 +1872,15 @@ dependencies = [ "syn", ] +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + [[package]] name = "quinn" version = "0.11.11" @@ -1520,6 +1995,12 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.9.5" @@ -1631,6 +2112,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime_guess", "percent-encoding", "pin-project-lite", "quinn", @@ -1699,6 +2181,15 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1799,6 +2290,12 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1809,6 +2306,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_core" version = "1.0.228" @@ -1929,6 +2435,18 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -2071,6 +2589,36 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinychannels" version = "0.1.0" @@ -2097,6 +2645,7 @@ dependencies = [ "rustls-pki-types", "schemars", "serde", + "serde-big-array", "serde_json", "sha1", "sha2", @@ -2110,7 +2659,11 @@ dependencies = [ "url", "urlencoding", "uuid", + "wacore", "webpki-roots 1.0.8", + "whatsapp-rust", + "whatsapp-rust-tokio-transport", + "whatsapp-rust-ureq-http-client", "wiremock", ] @@ -2205,6 +2758,27 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-websockets" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-sink", + "http", + "httparse", + "rand 0.10.2", + "ring", + "rustls-pki-types", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -2344,24 +2918,101 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "typed-builder" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "cookie_store", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots 1.0.8", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -2380,6 +3031,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -2409,6 +3066,148 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wacore" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5292fb723da7505e90cfd7a828dd397802b98e0f819a2bc058a6b3a9f2345306" +dependencies = [ + "aes", + "aes-gcm", + "anyhow", + "async-channel 2.5.0", + "async-lock", + "async-trait", + "base64", + "bytes", + "chrono", + "ctr", + "event-listener 5.4.1", + "flate2", + "futures", + "hex", + "hkdf", + "hmac", + "log", + "md5", + "once_cell", + "pbkdf2", + "prost", + "rand 0.10.2", + "serde", + "serde-big-array", + "serde_json", + "sha1", + "sha2", + "thiserror 2.0.18", + "typed-builder", + "wacore-appstate", + "wacore-binary", + "wacore-derive", + "wacore-libsignal", + "wacore-noise", + "waproto", +] + +[[package]] +name = "wacore-appstate" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab29b1c5198e16e2619868cc3e48c32f0bcecec936fd20d09460a8f7e374604d" +dependencies = [ + "anyhow", + "bytemuck", + "hex", + "hkdf", + "log", + "prost", + "serde", + "serde-big-array", + "serde_json", + "sha2", + "thiserror 2.0.18", + "wacore-binary", + "wacore-libsignal", + "waproto", +] + +[[package]] +name = "wacore-binary" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "127a3a6e7554ce002092f1711aa927b0efa3d3fb1ee83506525565c626e68834" +dependencies = [ + "flate2", + "phf", + "phf_codegen", + "serde", + "serde_json", +] + +[[package]] +name = "wacore-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9546a14730f112eca3bc2e4cbd815df88046fa78530f110c3665492604e770" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wacore-libsignal" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89676f7e09708d6b3a58e02bfea42000e3d7bffbc1bf67d0c8bf58ddbdf6373" +dependencies = [ + "aes", + "aes-gcm", + "arrayref", + "async-trait", + "cbc", + "chrono", + "ctr", + "curve25519-dalek", + "derive_more", + "displaydoc", + "ghash 0.6.0", + "hex", + "hkdf", + "hmac", + "log", + "prost", + "rand 0.10.2", + "serde", + "sha1", + "sha2", + "subtle", + "thiserror 2.0.18", + "uuid", + "waproto", + "x25519-dalek", +] + +[[package]] +name = "wacore-noise" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1c5a0ef2ddaf0f7c5a227649c09de5f354571d95feea4dbac55f2b31c2da53" +dependencies = [ + "aes-gcm", + "anyhow", + "bytes", + "hkdf", + "log", + "prost", + "rand 0.10.2", + "sha2", + "thiserror 2.0.18", + "wacore-binary", + "wacore-libsignal", + "waproto", +] + [[package]] name = "want" version = "0.3.1" @@ -2418,6 +3217,17 @@ dependencies = [ "try-lock", ] +[[package]] +name = "waproto" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fb5d942027d97e5ca1e542a9915388742cc5efab4802910ecc2fd1f430cefe" +dependencies = [ + "prost", + "prost-build", + "serde", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2539,6 +3349,70 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "whatsapp-rust" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b79e16847fe6e7ea8d103b4b575b1a1b77bd57cb4c292f7c50616b72a74374" +dependencies = [ + "anyhow", + "async-channel 2.5.0", + "async-lock", + "async-trait", + "base64", + "bytes", + "chrono", + "env_logger", + "event-listener 5.4.1", + "futures", + "hex", + "log", + "prost", + "rand 0.10.2", + "scopeguard", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "wacore", + "wacore-binary", + "waproto", +] + +[[package]] +name = "whatsapp-rust-tokio-transport" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aca6e068f01d56f7d360a04aeb16d34a3edb623e7d43e7cd857682a186c0130" +dependencies = [ + "anyhow", + "async-channel 2.5.0", + "async-trait", + "bytes", + "futures-util", + "http", + "log", + "rustls", + "tokio", + "tokio-rustls", + "tokio-websockets", + "wacore", + "webpki-roots 1.0.8", +] + +[[package]] +name = "whatsapp-rust-ureq-http-client" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45ae9349f172ab8e50031ef5c429bb56ffdd677fbfeb22ed09fff3ce3b62c135" +dependencies = [ + "anyhow", + "async-trait", + "tokio", + "ureq", + "wacore", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -2727,6 +3601,18 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[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 = "yoke" version = "0.8.3" @@ -2796,6 +3682,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" @@ -2830,6 +3730,12 @@ dependencies = [ "syn", ] +[[package]] +name = "zlib-rs" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5431d5661c32445236631278f27946e444ddafe4684cac70b185272d4f9c52d5" + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index d960b65..71870c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,15 @@ default = [] # `tokio/net` are now unconditional dependencies (the ported channel providers # use them directly), so this feature no longer needs to pull them in. relay-websocket = [] +# WhatsApp Web (multi-device) provider — pulls the native whatsapp-rust stack. +# Off by default; the OpenHuman `whatsapp-web` feature forwards to this one. +whatsapp-web = [ + "dep:whatsapp-rust", + "dep:whatsapp-rust-tokio-transport", + "dep:whatsapp-rust-ureq-http-client", + "dep:wacore", + "dep:serde-big-array", +] [dependencies] anyhow = "1" @@ -30,7 +39,7 @@ hmac = "0.12" parking_lot = "0.12" prost = "0.14" rand = "0.10" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "stream"] } rusqlite = { version = "0.40", features = ["bundled"] } rustls = { version = "0.23", features = ["ring"] } rustls-pki-types = "1.14.0" @@ -40,7 +49,7 @@ serde_json = "1" sha1 = "0.10" sha2 = "0.10" thiserror = "2" -tokio = { version = "1", default-features = false, features = ["io-util", "macros", "net", "process", "rt", "sync", "time"] } +tokio = { version = "1", default-features = false, features = ["fs", "io-util", "macros", "net", "process", "rt", "sync", "time"] } tokio-rustls = "0.26.4" tokio-tungstenite = { version = "0.29", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] } tracing = "0.1" @@ -57,6 +66,13 @@ mail-parser = "0.11.2" # Lark/Feishu webhook (HTTP receive mode). axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio", "query", "ws", "macros"] } +# WhatsApp Web provider (behind the `whatsapp-web` feature). +serde-big-array = { version = "0.5", optional = true } +whatsapp-rust = { version = "0.5", optional = true, default-features = false, features = ["tokio-runtime"] } +whatsapp-rust-tokio-transport = { version = "0.5", optional = true, default-features = false } +whatsapp-rust-ureq-http-client = { version = "0.5", optional = true } +wacore = { version = "0.5", optional = true, default-features = false } + [dev-dependencies] tempfile = "3" toml = "0.9" diff --git a/src/host/mod.rs b/src/host/mod.rs index 85dd1e3..5ea2abb 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -42,10 +42,10 @@ mod services; pub use dispatch::{DispatchOptions, DispatchRequest, TurnDispatcher, TurnHandle}; pub use noop::{ChannelHostBuilder, NoopHost}; pub use services::{ - ApprovalAsk, ApprovalDecision, ApprovalGate, ConversationMessage, ConversationStore, EventSink, - LifecycleRegistry, ReactionDecision, ReactionGate, RunEventAppend, RunLedger, RunTelemetry, - RunUpsert, ShutdownHook, SpeechRequest, SpeechResult, SpeechSynthesizer, Transcriber, - TranscriptionRequest, TranscriptionResult, + AllowlistStore, ApprovalAsk, ApprovalDecision, ApprovalGate, ConversationMessage, + ConversationStore, EventSink, LifecycleRegistry, ReactionDecision, ReactionGate, ReactionQuery, + RunEventAppend, RunLedger, RunTelemetry, RunUpsert, ShutdownHook, SpeechRequest, SpeechResult, + SpeechSynthesizer, Transcriber, TranscriptionRequest, TranscriptionResult, }; use crate::config::ChannelsConfig; @@ -72,6 +72,7 @@ pub struct HostCapabilities { pub event_sink: bool, pub lifecycle: bool, pub run_ledger: bool, + pub allowlist_store: bool, } impl HostCapabilities { @@ -87,6 +88,7 @@ impl HostCapabilities { event_sink: false, lifecycle: false, run_ledger: false, + allowlist_store: false, }; /// Derive the descriptor from a live host by probing each accessor. @@ -102,6 +104,7 @@ impl HostCapabilities { event_sink: host.events().is_some(), lifecycle: host.lifecycle().is_some(), run_ledger: host.ledger().is_some(), + allowlist_store: host.allowlist().is_some(), } } @@ -159,6 +162,10 @@ pub trait ChannelHost: Send + Sync { fn ledger(&self) -> Option> { None } + /// Persisted allowlist store (promote paired/authorized identities). + fn allowlist(&self) -> Option> { + None + } /// Snapshot of available capabilities. Defaults to probing the accessors; /// hosts may override with a cached descriptor. @@ -174,6 +181,7 @@ pub trait ChannelHost: Send + Sync { event_sink: self.events().is_some(), lifecycle: self.lifecycle().is_some(), run_ledger: self.ledger().is_some(), + allowlist_store: self.allowlist().is_some(), } } } diff --git a/src/host/noop.rs b/src/host/noop.rs index 4939187..b2773b3 100644 --- a/src/host/noop.rs +++ b/src/host/noop.rs @@ -7,8 +7,8 @@ //! (e.g. dispatcher + STT this week, ledger next) without a bespoke host type. use super::{ - ApprovalGate, ChannelHost, ConversationStore, EventSink, LifecycleRegistry, Memory, - ReactionGate, RunLedger, SpeechSynthesizer, Transcriber, TurnDispatcher, + AllowlistStore, ApprovalGate, ChannelHost, ConversationStore, EventSink, LifecycleRegistry, + Memory, ReactionGate, RunLedger, SpeechSynthesizer, Transcriber, TurnDispatcher, }; use std::sync::Arc; @@ -45,6 +45,7 @@ pub struct ChannelHostBuilder { events: Option>, lifecycle: Option>, ledger: Option>, + allowlist: Option>, } impl ChannelHostBuilder { @@ -92,6 +93,10 @@ impl ChannelHostBuilder { self.ledger = Some(value); self } + pub fn allowlist(mut self, value: Arc) -> Self { + self.allowlist = Some(value); + self + } /// Finalize into a shareable host. pub fn build(self) -> Arc { @@ -106,6 +111,7 @@ impl ChannelHostBuilder { events: self.events, lifecycle: self.lifecycle, ledger: self.ledger, + allowlist: self.allowlist, }) } } @@ -122,6 +128,7 @@ struct CompositeHost { events: Option>, lifecycle: Option>, ledger: Option>, + allowlist: Option>, } impl ChannelHost for CompositeHost { @@ -155,4 +162,7 @@ impl ChannelHost for CompositeHost { fn ledger(&self) -> Option> { self.ledger.clone() } + fn allowlist(&self) -> Option> { + self.allowlist.clone() + } } diff --git a/src/host/services.rs b/src/host/services.rs index a1c9ae5..c9bf0d4 100644 --- a/src/host/services.rs +++ b/src/host/services.rs @@ -107,7 +107,14 @@ pub enum ApprovalDecision { #[async_trait] pub trait ApprovalGate: Send + Sync { /// Raise an approval and await its resolution (blocks the turn). - async fn request(&self, ask: ApprovalAsk) -> anyhow::Result; + /// + /// Defaults to an "unsupported" error: in OpenHuman today approvals are + /// *raised* by the tool gate (host-internal) and channels only observe the + /// surface + [`ApprovalGate::parse_reply`] inbound replies. Hosts that can + /// mediate interactive requests override this. + async fn request(&self, _ask: ApprovalAsk) -> anyhow::Result { + anyhow::bail!("interactive approval requests are not supported by this host") + } /// Interpret a free-text inbound reply as an approval decision /// (`"yes"`/`"no"`/`"1"`), or `None` if it isn't one. Default: no parse. @@ -128,15 +135,21 @@ pub struct ReactionQuery { pub channel_type: String, } -/// Whether the assistant should react, with an optional rationale. +/// Whether the assistant should react, plus the emoji to use for an emoji +/// reaction (`presentation`/`telegram` ACK reactions) and an optional rationale. #[derive(Debug, Clone, PartialEq, Default)] pub struct ReactionDecision { pub should_react: bool, + /// Emoji to react with when `should_react` is true (`None` = no emoji, + /// e.g. a pure suppress/allow gate). Mirrors OpenHuman's `ReactionDecision`. + pub emoji: Option, + /// Optional human-readable rationale for logs/telemetry. pub reason: Option, } -/// Inference-driven "should I respond?" gate for busy group channels. -/// Needed by: **web, telegram, presentation** (group-chat suppression). +/// Inference-driven reaction gate: whether to respond/react and with what +/// emoji. Backs both group-chat suppression and emoji-ACK reactions. +/// Needed by: **web, telegram, presentation**. #[async_trait] pub trait ReactionGate: Send + Sync { async fn should_react(&self, query: ReactionQuery) -> anyhow::Result; @@ -195,8 +208,13 @@ pub trait EventSink: Send + Sync { // Lifecycle registry (graceful shutdown) // --------------------------------------------------------------------------- -/// A one-shot cleanup callback run at process shutdown. -pub type ShutdownHook = Box; +/// The future a [`ShutdownHook`] returns when invoked. +pub type ShutdownFuture = std::pin::Pin + Send + 'static>>; + +/// A one-shot **async** cleanup callback run at process shutdown. Async so a +/// hook can await real teardown (close a WebSocket, flush a session) — mirrors +/// the host's own async shutdown-hook contract. +pub type ShutdownHook = Box ShutdownFuture + Send + 'static>; /// Register cleanup that must run when the host shuts down (close sockets, /// flush sessions). Needed by: **whatsapp_web** (WA session teardown), any @@ -206,6 +224,21 @@ pub trait LifecycleRegistry: Send + Sync { fn register_shutdown(&self, name: &str, hook: ShutdownHook); } +// --------------------------------------------------------------------------- +// Allowlist store (persisted access control) +// --------------------------------------------------------------------------- + +/// Persist a newly-authorized identity into a channel's configured allowlist so +/// it survives restarts. Needed by: **telegram** (first-run bind-code pairing +/// promotes the paired account into `allowed_users`). The host owns the config +/// file; the provider only names the channel + identity. +#[async_trait] +pub trait AllowlistStore: Send + Sync { + /// Add `identity` to `channel`'s persisted allowlist (idempotent). Errors + /// if the channel has no config section to persist into. + async fn persist_allowed_identity(&self, channel: &str, identity: &str) -> anyhow::Result<()>; +} + // --------------------------------------------------------------------------- // Run ledger (telemetry / observability) // --------------------------------------------------------------------------- diff --git a/src/lib.rs b/src/lib.rs index 49aa48d..ad6a42f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,6 +24,7 @@ pub mod providers; pub mod relay; pub mod routes; pub mod runtime; +pub mod security; pub mod text; pub mod traits; @@ -41,6 +42,6 @@ pub use host::{ChannelHost, ChannelHostBuilder, HostCapabilities, NoopHost, Prov pub use providers::{ DingTalkChannel, DiscordChannel, EmailChannel, IMessageChannel, IrcChannel, IrcChannelConfig, LarkChannel, LinqChannel, MattermostChannel, QQChannel, SignalChannel, SlackChannel, - WhatsAppChannel, YuanbaoChannel, + TelegramChannel, WhatsAppChannel, WhatsAppWebChannel, YuanbaoChannel, }; pub use traits::{Channel, ChannelMessage, ChannelSendExt, SendMessage}; diff --git a/src/providers/discord/api.rs b/src/providers/discord/api.rs index 5e1dd3c..97329ef 100644 --- a/src/providers/discord/api.rs +++ b/src/providers/discord/api.rs @@ -330,12 +330,11 @@ async fn check_channel_permissions_at_base( let is_everyone = role_id == guild_id; // @everyone role ID == guild ID let is_member_role = member_role_ids.contains(&role_id); - if is_everyone || is_member_role { - if let Some(perms_str) = role.get("permissions").and_then(|p| p.as_str()) { - if let Ok(perms) = perms_str.parse::() { - permissions |= perms; - } - } + if (is_everyone || is_member_role) + && let Some(perms_str) = role.get("permissions").and_then(|p| p.as_str()) + && let Ok(perms) = perms_str.parse::() + { + permissions |= perms; } } diff --git a/src/providers/email_channel.rs b/src/providers/email_channel.rs index 8296d76..abaaab6 100644 --- a/src/providers/email_channel.rs +++ b/src/providers/email_channel.rs @@ -115,13 +115,12 @@ impl EmailChannel { } for part in parsed.attachments() { let part: &mail_parser::MessagePart = part; - if let Some(ct) = MimeHeaders::content_type(part) { - if ct.ctype() == "text" { - if let Ok(text) = std::str::from_utf8(part.contents()) { - let name = MimeHeaders::attachment_name(part).unwrap_or("file"); - return format!("[Attachment: {}]\n{}", name, text); - } - } + if let Some(ct) = MimeHeaders::content_type(part) + && ct.ctype() == "text" + && let Ok(text) = std::str::from_utf8(part.contents()) + { + let name = MimeHeaders::attachment_name(part).unwrap_or("file"); + return format!("[Attachment: {}]\n{}", name, text); } } "(no readable content)".to_string() @@ -182,50 +181,50 @@ impl EmailChannel { for msg in messages { let uid = msg.uid.unwrap_or(0); - if let Some(body) = msg.body() { - if let Some(parsed) = MessageParser::default().parse(body) { - let sender = Self::extract_sender(&parsed); - let subject = parsed.subject().unwrap_or("(no subject)").to_string(); - let body_text = Self::extract_text(&parsed); - let content = format!("Subject: {}\n\n{}", subject, body_text); - let msg_id = parsed - .message_id() - .map(|s| s.to_string()) - .unwrap_or_else(|| format!("gen-{}", Uuid::new_v4())); - - #[allow(clippy::cast_sign_loss)] - let ts = parsed - .date() - .map(|d| { - let naive = chrono::NaiveDate::from_ymd_opt( - d.year as i32, - u32::from(d.month), - u32::from(d.day), + if let Some(body) = msg.body() + && let Some(parsed) = MessageParser::default().parse(body) + { + let sender = Self::extract_sender(&parsed); + let subject = parsed.subject().unwrap_or("(no subject)").to_string(); + let body_text = Self::extract_text(&parsed); + let content = format!("Subject: {}\n\n{}", subject, body_text); + let msg_id = parsed + .message_id() + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("gen-{}", Uuid::new_v4())); + + #[allow(clippy::cast_sign_loss)] + let ts = parsed + .date() + .map(|d| { + let naive = chrono::NaiveDate::from_ymd_opt( + d.year as i32, + u32::from(d.month), + u32::from(d.day), + ) + .and_then(|date| { + date.and_hms_opt( + u32::from(d.hour), + u32::from(d.minute), + u32::from(d.second), ) - .and_then(|date| { - date.and_hms_opt( - u32::from(d.hour), - u32::from(d.minute), - u32::from(d.second), - ) - }); - naive.map_or(0, |n| n.and_utc().timestamp() as u64) - }) - .unwrap_or_else(|| { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) }); - - results.push(ParsedEmail { - _uid: uid, - msg_id, - sender, - content, - timestamp: ts, + naive.map_or(0, |n| n.and_utc().timestamp() as u64) + }) + .unwrap_or_else(|| { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) }); - } + + results.push(ParsedEmail { + _uid: uid, + msg_id, + sender, + content, + timestamp: ts, + }); } } diff --git a/src/providers/imessage.rs b/src/providers/imessage.rs index acf0068..f8f79e6 100644 --- a/src/providers/imessage.rs +++ b/src/providers/imessage.rs @@ -259,6 +259,7 @@ end tell"# } } +#[allow(dead_code)] /// Get the current max ROWID from the messages table. /// Uses rusqlite with parameterized queries for security (CWE-89 prevention). async fn get_max_rowid(db_path: &Path) -> anyhow::Result { @@ -279,6 +280,7 @@ async fn get_max_rowid(db_path: &Path) -> anyhow::Result { /// Fetch messages newer than `since_rowid`. /// Uses rusqlite with parameterized queries for security (CWE-89 prevention). /// The `since_rowid` parameter is bound safely, preventing SQL injection. +#[allow(dead_code)] async fn fetch_new_messages( db_path: &Path, since_rowid: i64, diff --git a/src/providers/irc.rs b/src/providers/irc.rs index 3a51eed..b756593 100644 --- a/src/providers/irc.rs +++ b/src/providers/irc.rs @@ -617,6 +617,7 @@ pub mod test_support { use super::*; + #[allow(clippy::type_complexity)] pub fn parse_line_for_test( line: &str, ) -> Option<(Option, String, Vec, Option)> { diff --git a/src/providers/lark.rs b/src/providers/lark.rs index ae3beda..637aab1 100644 --- a/src/providers/lark.rs +++ b/src/providers/lark.rs @@ -354,10 +354,10 @@ impl LarkChannel { // CONTROL frame if frame.method == 0 { - if frame.header_value("type") == "pong" { - if let Some(p) = &frame.payload { - if let Ok(cfg) = serde_json::from_slice::(p) { - if let Some(secs) = cfg.ping_interval { + if frame.header_value("type") == "pong" + && let Some(p) = &frame.payload + && let Ok(cfg) = serde_json::from_slice::(p) + && let Some(secs) = cfg.ping_interval { let secs = secs.max(10); if secs != ping_secs { ping_secs = secs; @@ -365,9 +365,6 @@ impl LarkChannel { tracing::info!("Lark: ping_interval → {ping_secs}s"); } } - } - } - } continue; } diff --git a/src/providers/mattermost.rs b/src/providers/mattermost.rs index f504a06..d2466c0 100644 --- a/src/providers/mattermost.rs +++ b/src/providers/mattermost.rs @@ -215,10 +215,10 @@ impl Channel for MattermostChannel { .unwrap_or(last_create_at); last_create_at = last_create_at.max(create_at); - if let Some(channel_msg) = msg { - if tx.send(channel_msg).await.is_err() { - return Ok(()); - } + if let Some(channel_msg) = msg + && tx.send(channel_msg).await.is_err() + { + return Ok(()); } } } @@ -265,10 +265,9 @@ impl Channel for MattermostChannel { .json(&body) .send() .await + && !r.status().is_success() { - if !r.status().is_success() { - tracing::debug!(status = %r.status(), "Mattermost typing indicator failed"); - } + tracing::debug!(status = %r.status(), "Mattermost typing indicator failed"); } // Mattermost typing events expire after ~6s; re-fire every 4s. @@ -365,16 +364,14 @@ fn contains_bot_mention_mm( } // 2. Metadata-based: Mattermost may include a "metadata.mentions" array of user IDs. - if !bot_user_id.is_empty() { - if let Some(mentions) = post + if !bot_user_id.is_empty() + && let Some(mentions) = post .get("metadata") .and_then(|m| m.get("mentions")) .and_then(|m| m.as_array()) - { - if mentions.iter().any(|m| m.as_str() == Some(bot_user_id)) { - return true; - } - } + && mentions.iter().any(|m| m.as_str() == Some(bot_user_id)) + { + return true; } false diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 5fef92e..0bed6e8 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -11,7 +11,9 @@ pub mod mattermost; pub mod qq; pub mod signal; pub mod slack; +pub mod telegram; pub mod whatsapp; +pub mod whatsapp_web; pub mod yuanbao; pub use dingtalk::DingTalkChannel; @@ -25,5 +27,7 @@ pub use mattermost::MattermostChannel; pub use qq::QQChannel; pub use signal::SignalChannel; pub use slack::SlackChannel; +pub use telegram::TelegramChannel; pub use whatsapp::WhatsAppChannel; +pub use whatsapp_web::WhatsAppWebChannel; pub use yuanbao::YuanbaoChannel; diff --git a/src/providers/qq.rs b/src/providers/qq.rs index 02436ea..23c037b 100644 --- a/src/providers/qq.rs +++ b/src/providers/qq.rs @@ -125,10 +125,10 @@ impl QQChannel { { let cache = self.token_cache.read().await; - if let Some((ref token, expiry)) = *cache { - if now < expiry { - return Ok(token.clone()); - } + if let Some((ref token, expiry)) = *cache + && now < expiry + { + return Ok(token.clone()); } } diff --git a/src/providers/telegram/attachments.rs b/src/providers/telegram/attachments.rs new file mode 100644 index 0000000..bca0ecc --- /dev/null +++ b/src/providers/telegram/attachments.rs @@ -0,0 +1,130 @@ +//! Attachment marker parsing and path/url detection. + +use std::path::Path; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TelegramAttachmentKind { + Image, + Document, + Video, + Audio, + Voice, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TelegramAttachment { + pub(crate) kind: TelegramAttachmentKind, + pub(crate) target: String, +} + +impl TelegramAttachmentKind { + fn from_marker(marker: &str) -> Option { + match marker.trim().to_ascii_uppercase().as_str() { + "IMAGE" | "PHOTO" => Some(Self::Image), + "DOCUMENT" | "FILE" => Some(Self::Document), + "VIDEO" => Some(Self::Video), + "AUDIO" => Some(Self::Audio), + "VOICE" => Some(Self::Voice), + _ => None, + } + } +} + +pub(crate) fn is_http_url(target: &str) -> bool { + target.starts_with("http://") || target.starts_with("https://") +} + +pub(crate) fn infer_attachment_kind_from_target(target: &str) -> Option { + let normalized = target + .split('?') + .next() + .unwrap_or(target) + .split('#') + .next() + .unwrap_or(target); + + let extension = Path::new(normalized) + .extension() + .and_then(|ext| ext.to_str())? + .to_ascii_lowercase(); + + match extension.as_str() { + "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" => Some(TelegramAttachmentKind::Image), + "mp4" | "mov" | "mkv" | "avi" | "webm" => Some(TelegramAttachmentKind::Video), + "mp3" | "m4a" | "wav" | "flac" => Some(TelegramAttachmentKind::Audio), + "ogg" | "oga" | "opus" => Some(TelegramAttachmentKind::Voice), + "pdf" | "txt" | "md" | "csv" | "json" | "zip" | "tar" | "gz" | "doc" | "docx" | "xls" + | "xlsx" | "ppt" | "pptx" => Some(TelegramAttachmentKind::Document), + _ => None, + } +} + +pub(crate) fn parse_path_only_attachment(message: &str) -> Option { + let trimmed = message.trim(); + if trimmed.is_empty() || trimmed.contains('\n') { + return None; + } + + let candidate = trimmed.trim_matches(|c| matches!(c, '`' | '"' | '\'')); + if candidate.chars().any(char::is_whitespace) { + return None; + } + + let candidate = candidate.strip_prefix("file://").unwrap_or(candidate); + let kind = infer_attachment_kind_from_target(candidate)?; + + if !is_http_url(candidate) && !Path::new(candidate).exists() { + return None; + } + + Some(TelegramAttachment { + kind, + target: candidate.to_string(), + }) +} + +pub(crate) fn parse_attachment_markers(message: &str) -> (String, Vec) { + let mut cleaned = String::with_capacity(message.len()); + let mut attachments = Vec::new(); + let mut cursor = 0; + + while cursor < message.len() { + let Some(open_rel) = message[cursor..].find('[') else { + cleaned.push_str(&message[cursor..]); + break; + }; + + let open = cursor + open_rel; + cleaned.push_str(&message[cursor..open]); + + let Some(close_rel) = message[open..].find(']') else { + cleaned.push_str(&message[open..]); + break; + }; + + let close = open + close_rel; + let marker = &message[open + 1..close]; + + let parsed = marker.split_once(':').and_then(|(kind, target)| { + let kind = TelegramAttachmentKind::from_marker(kind)?; + let target = target.trim(); + if target.is_empty() { + return None; + } + Some(TelegramAttachment { + kind, + target: target.to_string(), + }) + }); + + if let Some(attachment) = parsed { + attachments.push(attachment); + } else { + cleaned.push_str(&message[open..=close]); + } + + cursor = close + 1; + } + + (cleaned.trim().to_string(), attachments) +} diff --git a/src/providers/telegram/channel.rs b/src/providers/telegram/channel.rs new file mode 100644 index 0000000..c6c1a5d --- /dev/null +++ b/src/providers/telegram/channel.rs @@ -0,0 +1,21 @@ +//! Telegram Bot API channel implementation. +//! +//! This module is the orchestration entry point for the Telegram channel. +//! Implementation is split across sibling modules by concern: +//! +//! - [`super::channel_types`] — struct definition and private helper types +//! - [`super::channel_core`] — constructor, config, pairing/auth, API plumbing +//! - [`super::channel_recv`] — inbound parsing, allowlist checks, mention filtering +//! - [`super::channel_send`] — outbound text, media, reactions, attachments +//! - [`super::channel_ops`] — `Channel` trait impl (send/listen/draft/typing) + +// Re-export so that the `#[path = "channel_tests.rs"]` test module can reach +// `TelegramChannel` via `super::TelegramChannel`. +#[allow(unused_imports)] +pub use super::channel_types::TelegramChannel; +#[cfg(test)] +pub(super) use super::channel_types::TelegramTypingTask; + +#[cfg(test)] +#[path = "channel_tests.rs"] +mod tests; diff --git a/src/providers/telegram/channel_core.rs b/src/providers/telegram/channel_core.rs new file mode 100644 index 0000000..b19ba7b --- /dev/null +++ b/src/providers/telegram/channel_core.rs @@ -0,0 +1,341 @@ +//! Telegram channel — constructor, configuration, auth/pairing, and API plumbing helpers. + +use super::channel_types::{ + TELEGRAM_RECENT_UPDATE_CACHE_SIZE, TelegramChannel, TelegramUpdateWindow, +}; +use super::text::{TELEGRAM_BIND_COMMAND, TELEGRAM_START_COMMAND}; +use crate::config::StreamMode; +use crate::security::PairingGuard; +use anyhow::Context; +use std::sync::{Arc, RwLock}; + +/// Resolve the Telegram API base URL from an optional env value. Pure function — +/// callers in production pass `std::env::var("OPENHUMAN_TELEGRAM_BOT_API_BASE").ok()` +/// (falling back to the legacy `OPENHUMAN_TELEGRAM_API_BASE`); +/// tests can exercise this directly without mutating process env. +pub(crate) fn resolve_api_base(raw: Option) -> String { + let base = raw + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| "https://api.telegram.org".to_string()); + base.trim_end_matches('/').to_string() +} + +impl TelegramChannel { + pub fn new(bot_token: String, allowed_users: Vec, mention_only: bool) -> Self { + let api_base = resolve_api_base( + std::env::var("OPENHUMAN_TELEGRAM_BOT_API_BASE") + .ok() + .or_else(|| std::env::var("OPENHUMAN_TELEGRAM_API_BASE").ok()), + ); + tracing::debug!( + target: "telegram::api", + api_base = %api_base, + "Using Telegram API base URL" + ); + + let normalized_allowed = Self::normalize_allowed_users(allowed_users); + let pairing = if normalized_allowed.is_empty() { + let (guard, code_opt) = PairingGuard::new(true, &[]); + if let Some(code) = code_opt { + println!(" 🔐 Telegram pairing required. One-time bind code: {code}"); + println!(" Send `{TELEGRAM_BIND_COMMAND} ` from your Telegram account."); + } + Some(guard) + } else { + None + }; + + Self { + bot_token, + chat_id: None, + api_base, + allowed_users: Arc::new(RwLock::new(normalized_allowed)), + pairing, + client: reqwest::Client::new(), + transcriber: None, + allowlist: None, + events: None, + stream_mode: StreamMode::Off, + draft_update_interval_ms: 1000, + silent_streaming: true, + last_draft_edit: parking_lot::Mutex::new(std::collections::HashMap::new()), + typing_handle: parking_lot::Mutex::new(None), + mention_only, + bot_username: parking_lot::Mutex::new(None), + recent_updates: parking_lot::Mutex::new(TelegramUpdateWindow::default()), + recent_approval_prompts: parking_lot::Mutex::new(std::collections::HashMap::new()), + } + } + + /// Configure streaming mode for progressive draft updates. + /// Configure streaming mode for progressive draft updates. + pub fn with_streaming( + mut self, + stream_mode: StreamMode, + draft_update_interval_ms: u64, + silent_streaming: bool, + ) -> Self { + self.stream_mode = stream_mode; + self.draft_update_interval_ms = draft_update_interval_ms; + self.silent_streaming = silent_streaming; + self + } + + /// Set the default chat for recipient-less proactive sends. A blank or + /// whitespace-only value is treated as unset (`None`), so proactive routing + /// skips Telegram rather than POSTing to an empty `chat_id`. + pub fn with_chat_id(mut self, chat_id: Option) -> Self { + self.chat_id = chat_id + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + self + } + + /// Inject the (optionally proxied) HTTP client used for all outbound + /// Telegram Bot API calls. The host builds this — including runtime proxy + /// configuration — and hands it in, replacing the direct proxy lookup that + /// lived in OpenHuman's config layer. + pub fn with_http_client(mut self, client: reqwest::Client) -> Self { + self.client = client; + self + } + + /// Inject a speech-to-text capability for inbound voice notes. Without it, + /// voice notes are received but not transcribed. + pub fn with_transcriber(mut self, transcriber: Arc) -> Self { + self.transcriber = Some(transcriber); + self + } + + /// Inject a persisted allowlist store so first-run bind/pairing promotions + /// survive restarts. Without it, allowlisting is runtime-only. + pub fn with_allowlist(mut self, allowlist: Arc) -> Self { + self.allowlist = Some(allowlist); + self + } + + /// Inject a domain event sink for reaction fan-out (received/sent). + pub fn with_events(mut self, events: Arc) -> Self { + self.events = Some(events); + self + } + + /// Parse reply_target into (chat_id, optional thread_id). + pub(crate) fn parse_reply_target(reply_target: &str) -> (String, Option) { + if let Some((chat_id, thread_id)) = reply_target.split_once(':') { + (chat_id.to_string(), Some(thread_id.to_string())) + } else { + (reply_target.to_string(), None) + } + } + + pub(crate) fn parse_message_id(value: Option<&str>) -> Option { + value.and_then(|raw| raw.trim().parse::().ok()) + } + + pub(crate) fn http_client(&self) -> reqwest::Client { + self.client.clone() + } + + pub(crate) fn normalize_identity(value: &str) -> String { + value.trim().trim_start_matches('@').to_string() + } + + pub(crate) fn normalize_allowed_users(allowed_users: Vec) -> Vec { + allowed_users + .into_iter() + .map(|entry| Self::normalize_identity(&entry)) + .filter(|entry| !entry.is_empty()) + .collect() + } + + pub(crate) fn api_url(&self, method: &str) -> String { + format!("{}/bot{}/{method}", self.api_base, self.bot_token) + } + + /// Point outbound Telegram API calls at `base` (test-only seam). Used to + /// aim `send()` at a dead local port so onboarding tests exercise the + /// decision logic without reaching api.telegram.org. + #[cfg(test)] + pub(crate) fn set_api_base_for_tests(&mut self, base: impl Into) { + self.api_base = base.into(); + } + + pub(crate) fn pairing_code_active(&self) -> bool { + self.pairing + .as_ref() + .and_then(PairingGuard::pairing_code) + .is_some() + } + + pub(crate) fn extract_bind_code(text: &str) -> Option<&str> { + let mut parts = text.split_whitespace(); + let command = parts.next()?; + let base_command = command.split('@').next().unwrap_or(command); + if base_command != TELEGRAM_BIND_COMMAND { + return None; + } + parts.next().map(str::trim).filter(|code| !code.is_empty()) + } + + /// Whether `text` is the standard Telegram `/start` bot-onboarding command + /// (optionally addressed as `/start@botname`, with or without a payload). + /// + /// On the self-bot-token path this is the operator's explicit "I'm setting up + /// my bot" signal: the first `/start` while pairing is still pending pairs the + /// sender (see `handle_unauthorized_message`), matching the "first sender after + /// /start" behaviour sanctioned by openhuman#4381. + pub(crate) fn is_start_command(text: &str) -> bool { + let Some(command) = text.split_whitespace().next() else { + return false; + }; + let base_command = command.split('@').next().unwrap_or(command); + base_command == TELEGRAM_START_COMMAND + } + + pub(crate) fn track_update_id(&self, update_id: i64) -> bool { + let mut window = self.recent_updates.lock(); + if window.recent_lookup.contains(&update_id) { + tracing::debug!( + update_id, + "Telegram update dedupe hit: duplicate update skipped" + ); + return false; + } + + if update_id < window.max_seen_update_id { + tracing::debug!( + update_id, + max_seen = window.max_seen_update_id, + "Telegram update ordering safeguard: stale update skipped" + ); + return false; + } + + if update_id > window.max_seen_update_id { + window.max_seen_update_id = update_id; + } + + window.recent_lookup.insert(update_id); + window.recent_order.push_back(update_id); + if window.recent_order.len() > TELEGRAM_RECENT_UPDATE_CACHE_SIZE + && let Some(evicted) = window.recent_order.pop_front() + { + window.recent_lookup.remove(&evicted); + } + true + } + + /// Clears Bot API webhook mode so `getUpdates` long polling can run. + pub(crate) async fn delete_webhook_for_long_polling(&self) -> bool { + let url = self.api_url("deleteWebhook"); + let body = serde_json::json!({ "drop_pending_updates": false }); + tracing::info!( + "[telegram] deleteWebhook: enabling getUpdates polling (drop_pending_updates=false)" + ); + match self.http_client().post(&url).json(&body).send().await { + Ok(resp) => Self::telegram_api_ok(resp).await, + Err(e) => { + tracing::warn!(error = %e, "[telegram] deleteWebhook HTTP request failed"); + false + } + } + } + + pub(crate) async fn telegram_api_ok(resp: reqwest::Response) -> bool { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + if !status.is_success() { + tracing::warn!(status = ?status, body, "Telegram API request failed"); + return false; + } + + match serde_json::from_str::(&body) { + Ok(payload) => { + if payload + .get("ok") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + true + } else { + let error_code = payload + .get("error_code") + .and_then(serde_json::Value::as_i64) + .unwrap_or_default(); + let description = payload + .get("description") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown Telegram API error"); + tracing::warn!( + status = ?status, + error_code, + description, + body, + "Telegram API responded with ok=false" + ); + false + } + } + Err(error) => { + tracing::warn!( + status = ?status, + %error, + body, + "Telegram API returned non-JSON body" + ); + false + } + } + } + + pub(crate) async fn fetch_bot_username(&self) -> anyhow::Result { + let resp = self.http_client().get(self.api_url("getMe")).send().await?; + + if !resp.status().is_success() { + anyhow::bail!("Failed to fetch bot info: {}", resp.status()); + } + + let data: serde_json::Value = resp.json().await?; + let username = data + .get("result") + .and_then(|r| r.get("username")) + .and_then(|u| u.as_str()) + .context("Bot username not found in response")?; + + Ok(username.to_string()) + } + + pub(crate) async fn get_bot_username(&self) -> Option { + { + let cache = self.bot_username.lock(); + if let Some(ref username) = *cache { + return Some(username.clone()); + } + } + + match self.fetch_bot_username().await { + Ok(username) => { + let mut cache = self.bot_username.lock(); + *cache = Some(username.clone()); + Some(username) + } + Err(e) => { + tracing::warn!("Failed to fetch bot username: {e}"); + None + } + } + } + + pub(crate) fn add_allowed_identity_runtime(&self, identity: &str) { + let normalized = Self::normalize_identity(identity); + if normalized.is_empty() { + return; + } + if let Ok(mut users) = self.allowed_users.write() + && !users.iter().any(|u| u == &normalized) + { + users.push(normalized); + } + } +} diff --git a/src/providers/telegram/channel_ops.rs b/src/providers/telegram/channel_ops.rs new file mode 100644 index 0000000..0c0a9f2 --- /dev/null +++ b/src/providers/telegram/channel_ops.rs @@ -0,0 +1,543 @@ +//! Telegram channel — `Channel` trait implementation: send, listen, draft streaming, typing. + +use super::attachments::{parse_attachment_markers, parse_path_only_attachment}; +use super::channel_types::{TelegramChannel, TelegramTypingTask}; +use super::text::{TELEGRAM_MAX_MESSAGE_LENGTH, strip_tool_call_tags}; +use crate::config::StreamMode; +use crate::traits::{Channel, ChannelMessage, SendMessage}; +use async_trait::async_trait; +use std::time::Duration; + +#[async_trait] +impl Channel for TelegramChannel { + fn name(&self) -> &str { + "telegram" + } + + /// Recipient-less proactive sends (cron/heartbeat) deliver to the bot's + /// configured default `chat_id`. `None` when unconfigured, so proactive + /// routing skips Telegram rather than letting `send` POST to an empty + /// `chat_id` (mirrors Discord — #3712 Telegram parity). + fn proactive_target(&self) -> Option { + self.chat_id + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + } + + fn supports_reactions(&self) -> bool { + true + } + + fn supports_draft_updates(&self) -> bool { + self.stream_mode != StreamMode::Off + } + + async fn send_draft(&self, message: &SendMessage) -> anyhow::Result> { + if self.stream_mode == StreamMode::Off { + return Ok(None); + } + + let (chat_id, thread_id) = Self::parse_reply_target(&message.recipient); + let parent_message_id = Self::parse_message_id(message.thread_ts.as_deref()); + let initial_text = if message.content.is_empty() { + "...".to_string() + } else { + message.content.clone() + }; + + let mut body = serde_json::json!({ + "chat_id": chat_id, + "text": initial_text, + }); + if let Some(tid) = thread_id { + body["message_thread_id"] = serde_json::Value::String(tid.to_string()); + } + if let Some(parent_id) = parent_message_id { + body["reply_to_message_id"] = serde_json::Value::from(parent_id); + } + + let resp = self + .client + .post(self.api_url("sendMessage")) + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await.unwrap_or_default(); + anyhow::bail!("Telegram sendMessage (draft) failed: {err}"); + } + + let resp_json: serde_json::Value = resp.json().await?; + let message_id = resp_json + .get("result") + .and_then(|r| r.get("message_id")) + .and_then(|id| id.as_i64()) + .map(|id| id.to_string()); + + self.last_draft_edit + .lock() + .insert(chat_id.to_string(), std::time::Instant::now()); + + Ok(message_id) + } + + async fn update_draft( + &self, + recipient: &str, + message_id: &str, + text: &str, + ) -> anyhow::Result<()> { + let (chat_id, _) = Self::parse_reply_target(recipient); + + // Rate-limit edits per chat + { + let last_edits = self.last_draft_edit.lock(); + if let Some(last_time) = last_edits.get(&chat_id) { + let elapsed = u64::try_from(last_time.elapsed().as_millis()).unwrap_or(u64::MAX); + if elapsed < self.draft_update_interval_ms { + return Ok(()); + } + } + } + + // Truncate to Telegram limit for mid-stream edits (UTF-8 safe) + let display_text = if text.len() > TELEGRAM_MAX_MESSAGE_LENGTH { + let mut end = 0; + for (idx, ch) in text.char_indices() { + let next = idx + ch.len_utf8(); + if next > TELEGRAM_MAX_MESSAGE_LENGTH { + break; + } + end = next; + } + &text[..end] + } else { + text + }; + + let message_id_parsed = match message_id.parse::() { + Ok(id) => id, + Err(e) => { + tracing::warn!("Invalid Telegram message_id '{message_id}': {e}"); + return Ok(()); + } + }; + + let body = serde_json::json!({ + "chat_id": chat_id, + "message_id": message_id_parsed, + "text": display_text, + }); + + let resp = self + .client + .post(self.api_url("editMessageText")) + .json(&body) + .send() + .await?; + + if resp.status().is_success() { + self.last_draft_edit + .lock() + .insert(chat_id.clone(), std::time::Instant::now()); + } else { + let status = resp.status(); + let err = resp.text().await.unwrap_or_default(); + tracing::debug!("Telegram editMessageText failed ({status}): {err}"); + } + + Ok(()) + } + + async fn finalize_draft( + &self, + recipient: &str, + message_id: &str, + text: &str, + thread_ts: Option<&str>, + ) -> anyhow::Result<()> { + let text = &strip_tool_call_tags(text); + let (chat_id, thread_id) = Self::parse_reply_target(recipient); + let parent_message_id = Self::parse_message_id(thread_ts); + + // Clean up rate-limit tracking for this chat + self.last_draft_edit.lock().remove(&chat_id); + + // If text exceeds limit, delete draft and send as chunked messages + if text.len() > TELEGRAM_MAX_MESSAGE_LENGTH { + let msg_id = match message_id.parse::() { + Ok(id) => id, + Err(e) => { + tracing::warn!("Invalid Telegram message_id '{message_id}': {e}"); + return self + .send_text_chunks(text, &chat_id, thread_id.as_deref(), parent_message_id) + .await; + } + }; + + // Delete the draft + let _ = self + .client + .post(self.api_url("deleteMessage")) + .json(&serde_json::json!({ + "chat_id": chat_id, + "message_id": msg_id, + })) + .send() + .await; + + // Fall back to chunked send + return self + .send_text_chunks(text, &chat_id, thread_id.as_deref(), parent_message_id) + .await; + } + + let msg_id = match message_id.parse::() { + Ok(id) => id, + Err(e) => { + tracing::warn!("Invalid Telegram message_id '{message_id}': {e}"); + return self + .send_text_chunks(text, &chat_id, thread_id.as_deref(), parent_message_id) + .await; + } + }; + + // Try editing with Markdown formatting + let body = serde_json::json!({ + "chat_id": chat_id, + "message_id": msg_id, + "text": text, + "parse_mode": "Markdown", + }); + + let resp = self + .client + .post(self.api_url("editMessageText")) + .json(&body) + .send() + .await?; + + if resp.status().is_success() { + return Ok(()); + } + + // Markdown failed — retry without parse_mode + let plain_body = serde_json::json!({ + "chat_id": chat_id, + "message_id": msg_id, + "text": text, + }); + + let resp = self + .client + .post(self.api_url("editMessageText")) + .json(&plain_body) + .send() + .await?; + + if resp.status().is_success() { + return Ok(()); + } + + // Edit failed entirely — fall back to new message + tracing::warn!("Telegram finalize_draft edit failed; falling back to sendMessage"); + self.send_text_chunks(text, &chat_id, thread_id.as_deref(), parent_message_id) + .await + } + + async fn send(&self, message: &SendMessage) -> anyhow::Result<()> { + // Strip tool_call tags before processing to prevent Markdown parsing failures + let content = strip_tool_call_tags(&message.content); + let parent_message_id = Self::parse_message_id(message.thread_ts.as_deref()); + + // Parse recipient: "chat_id" or "chat_id:thread_id" format + let (chat_id, thread_id) = match message.recipient.split_once(':') { + Some((chat, thread)) => (chat, Some(thread)), + None => (message.recipient.as_str(), None), + }; + + let (reactionless_content, reaction_marker) = Self::parse_reaction_marker(&content); + if let Some(reaction_marker) = reaction_marker.as_deref() { + let (emoji, explicit_target_id) = match reaction_marker.split_once('|') { + Some((emoji, target)) => (emoji.trim(), Self::parse_message_id(Some(target))), + None => (reaction_marker.trim(), None), + }; + let target_message_id = explicit_target_id.or(parent_message_id); + if let Some(target_id) = target_message_id { + let _ = self + .send_message_reaction(chat_id, target_id, emoji) + .await?; + tracing::debug!( + chat_id, + target_id, + emoji, + has_reply = !reactionless_content.is_empty(), + "[telegram] reaction sent; continuing to send reply text if present" + ); + } else { + tracing::warn!( + recipient = message.recipient, + marker = reaction_marker, + "[telegram] reaction marker ignored: missing target message id" + ); + } + // If no text follows the reaction marker, we are done. + if reactionless_content.trim().is_empty() { + return Ok(()); + } + } + + let (text_without_markers, attachments) = parse_attachment_markers(&reactionless_content); + + if !attachments.is_empty() { + if !text_without_markers.is_empty() { + self.send_text_chunks(&text_without_markers, chat_id, thread_id, parent_message_id) + .await?; + } + + for attachment in &attachments { + self.send_attachment(chat_id, thread_id, attachment).await?; + } + + return Ok(()); + } + + if let Some(attachment) = parse_path_only_attachment(&reactionless_content) { + self.send_attachment(chat_id, thread_id, &attachment) + .await?; + return Ok(()); + } + + self.send_text_chunks(&reactionless_content, chat_id, thread_id, parent_message_id) + .await + } + + async fn listen(&self, tx: tokio::sync::mpsc::Sender) -> anyhow::Result<()> { + let mut offset: i64 = 0; + + if self.mention_only { + let _ = self.get_bot_username().await; + } + + tracing::info!("Telegram channel listening for messages..."); + + loop { + if self.mention_only { + let missing_username = self.bot_username.lock().is_none(); + if missing_username { + let _ = self.get_bot_username().await; + } + } + + let url = self.api_url("getUpdates"); + let body = serde_json::json!({ + "offset": offset, + "timeout": 30, + "allowed_updates": ["message", "edited_message", "message_reaction"] + }); + + let resp = match self.http_client().post(&url).json(&body).send().await { + Ok(r) => r, + Err(e) => { + tracing::warn!("Telegram poll error: {e}"); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + continue; + } + }; + + let data: serde_json::Value = match resp.json().await { + Ok(d) => d, + Err(e) => { + tracing::warn!("Telegram parse error: {e}"); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + continue; + } + }; + + let ok = data + .get("ok") + .and_then(serde_json::Value::as_bool) + .unwrap_or(true); + if !ok { + let error_code = data + .get("error_code") + .and_then(serde_json::Value::as_i64) + .unwrap_or_default(); + let description = data + .get("description") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown Telegram API error"); + + if error_code == 409 { + let webhook_blocks_polling = description.to_lowercase().contains("webhook"); + if webhook_blocks_polling { + tracing::warn!( + "[telegram] getUpdates conflict (409): webhook is active; calling deleteWebhook" + ); + if self.delete_webhook_for_long_polling().await { + tracing::info!("[telegram] deleteWebhook ok; retrying getUpdates"); + continue; + } + tracing::warn!("[telegram] deleteWebhook did not succeed; backing off"); + } else { + tracing::warn!( + "Telegram polling conflict (409): {description}. \ +Ensure only one `openhuman` process is using this bot token." + ); + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } else { + tracing::warn!( + "Telegram getUpdates API error (code={}): {description}", + error_code + ); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } + continue; + } + + if let Some(results) = data.get("result").and_then(serde_json::Value::as_array) { + for update in results { + let update_id = update + .get("update_id") + .and_then(serde_json::Value::as_i64) + .unwrap_or_default(); + if update_id > 0 && !self.track_update_id(update_id) { + continue; + } + + // Advance offset past this update + if let Some(uid) = update.get("update_id").and_then(serde_json::Value::as_i64) { + offset = uid + 1; + } + + if let Some(reaction) = self.parse_update_reaction(update) { + tracing::info!( + sender = reaction.sender, + reply_target = reaction.reply_target, + target_message_id = reaction.target_message_id, + emoji = reaction.emoji, + "Telegram reaction received" + ); + if let Some(events) = &self.events { + events + .publish( + "channel", + "reaction_received", + serde_json::json!({ + "channel": "telegram", + "sender": reaction.sender, + "target_message_id": format!( + "telegram_{}_{}", + reaction.reply_target, reaction.target_message_id + ), + "emoji": reaction.emoji, + }), + ) + .await + .ok(); + } + continue; + } + + let Some(msg) = self.parse_update_message_or_voice(update).await else { + self.handle_unauthorized_message(update).await; + continue; + }; + + if tx.send(msg).await.is_err() { + return Ok(()); + } + } + } + } + } + + async fn health_check(&self) -> bool { + let timeout_duration = Duration::from_secs(5); + + match tokio::time::timeout( + timeout_duration, + self.http_client().get(self.api_url("getMe")).send(), + ) + .await + { + Ok(Ok(resp)) => resp.status().is_success(), + Ok(Err(e)) => { + tracing::debug!("Telegram health check failed: {e}"); + false + } + Err(_) => { + tracing::debug!("Telegram health check timed out after 5s"); + false + } + } + } + + async fn start_typing(&self, recipient: &str) -> anyhow::Result<()> { + tracing::info!(recipient, "Telegram start_typing invoked"); + // Emit immediately so short model turns still show "typing…" + self.send_typing_action_once(recipient).await; + + { + let guard = self.typing_handle.lock(); + if guard + .as_ref() + .is_some_and(|task| task.recipient == recipient) + { + return Ok(()); + } + } + self.stop_typing(recipient).await?; + + let client = self.http_client(); + let url = self.api_url("sendChatAction"); + let recipient_owned = recipient.to_string(); + let recipient_for_log = recipient_owned.clone(); + let body = Self::typing_body_for_recipient(recipient); + + let handle = tokio::spawn(async move { + loop { + match client.post(&url).json(&body).send().await { + Ok(resp) => { + if !Self::telegram_api_ok(resp).await { + tracing::warn!( + recipient = recipient_for_log, + "Telegram typing refresh rejected" + ); + } + } + Err(error) => { + tracing::warn!( + recipient = recipient_for_log, + %error, + "Telegram typing refresh request failed" + ); + } + } + // Telegram typing indicator expires after 5s; refresh at 4s + tokio::time::sleep(Duration::from_secs(4)).await; + } + }); + + let mut guard = self.typing_handle.lock(); + *guard = Some(TelegramTypingTask { + recipient: recipient_owned, + handle, + }); + + Ok(()) + } + + async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> { + tracing::info!("Telegram stop_typing invoked"); + let mut guard = self.typing_handle.lock(); + if let Some(task) = guard.take() { + task.handle.abort(); + } + Ok(()) + } +} diff --git a/src/providers/telegram/channel_recv.rs b/src/providers/telegram/channel_recv.rs new file mode 100644 index 0000000..681aa2a --- /dev/null +++ b/src/providers/telegram/channel_recv.rs @@ -0,0 +1,1250 @@ +//! Telegram channel — inbound message/reaction parsing, allowlist checks, mention filtering, +//! unauthorized-message handling, and typing-action helpers. + +use super::channel_types::{ + APPROVAL_PROMPT_DEBOUNCE_SECS, TELEGRAM_MAX_VOICE_FILE_BYTES, TelegramChannel, + TelegramReactionEvent, TelegramVoiceAttachment, +}; +use crate::traits::{ChannelMessage, ChannelSendExt, SendMessage}; +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use std::time::Instant; + +#[derive(Debug, Clone)] +pub(crate) struct TelegramIncomingMessageContext { + pub(crate) sender_identity: String, + pub(crate) reply_target: String, + pub(crate) chat_id: String, + pub(crate) message_id: i64, + pub(crate) mention_text: Option, +} + +impl TelegramChannel { + pub(crate) fn typing_body_for_recipient(recipient: &str) -> serde_json::Value { + let (chat_id, thread_id) = Self::parse_reply_target(recipient); + let mut body = serde_json::json!({ + "chat_id": chat_id, + "action": "typing" + }); + if let Some(thread_id) = thread_id { + body["message_thread_id"] = serde_json::Value::String(thread_id); + } + body + } + + pub(crate) async fn send_typing_action_once(&self, recipient: &str) { + tracing::info!(recipient, "Telegram typing action attempt"); + let body = Self::typing_body_for_recipient(recipient); + let has_thread_id = body.get("message_thread_id").is_some(); + match self + .http_client() + .post(self.api_url("sendChatAction")) + .json(&body) + .send() + .await + { + Ok(resp) => { + if Self::telegram_api_ok(resp).await { + tracing::info!(recipient, "Telegram typing action sent"); + return; + } + tracing::warn!(recipient, "Telegram typing action rejected"); + + // Some chats can reject thread-scoped chat actions; retry plain chat_id once. + if has_thread_id { + let (chat_id, _) = Self::parse_reply_target(recipient); + let fallback_body = serde_json::json!({ + "chat_id": chat_id, + "action": "typing" + }); + match self + .http_client() + .post(self.api_url("sendChatAction")) + .json(&fallback_body) + .send() + .await + { + Ok(fallback_resp) => { + if Self::telegram_api_ok(fallback_resp).await { + tracing::warn!( + recipient, + "Telegram typing action accepted after removing message_thread_id" + ); + } else { + tracing::warn!( + recipient, + "Telegram typing fallback (without message_thread_id) rejected" + ); + } + } + Err(fallback_error) => { + tracing::warn!( + recipient, + %fallback_error, + "Telegram typing fallback request failed" + ); + } + } + } + } + Err(error) => { + tracing::warn!(recipient, %error, "Telegram typing action request failed"); + } + } + } + + pub(crate) fn is_telegram_username_char(ch: char) -> bool { + ch.is_ascii_alphanumeric() || ch == '_' + } + + pub(crate) fn find_bot_mention_spans(text: &str, bot_username: &str) -> Vec<(usize, usize)> { + let bot_username = bot_username.trim_start_matches('@'); + if bot_username.is_empty() { + return Vec::new(); + } + + let mut spans = Vec::new(); + + for (at_idx, ch) in text.char_indices() { + if ch != '@' { + continue; + } + + if at_idx > 0 { + let prev = text[..at_idx].chars().next_back().unwrap_or(' '); + if Self::is_telegram_username_char(prev) { + continue; + } + } + + let username_start = at_idx + 1; + let mut username_end = username_start; + + for (rel_idx, candidate_ch) in text[username_start..].char_indices() { + if Self::is_telegram_username_char(candidate_ch) { + username_end = username_start + rel_idx + candidate_ch.len_utf8(); + } else { + break; + } + } + + if username_end == username_start { + continue; + } + + let mention_username = &text[username_start..username_end]; + if mention_username.eq_ignore_ascii_case(bot_username) { + spans.push((at_idx, username_end)); + } + } + + spans + } + + pub(crate) fn contains_bot_mention(text: &str, bot_username: &str) -> bool { + !Self::find_bot_mention_spans(text, bot_username).is_empty() + } + + pub(crate) fn normalize_incoming_content(text: &str, bot_username: &str) -> Option { + let spans = Self::find_bot_mention_spans(text, bot_username); + if spans.is_empty() { + let normalized = text.split_whitespace().collect::>().join(" "); + return (!normalized.is_empty()).then_some(normalized); + } + + let mut normalized = String::with_capacity(text.len()); + let mut cursor = 0; + for (start, end) in spans { + normalized.push_str(&text[cursor..start]); + cursor = end; + } + normalized.push_str(&text[cursor..]); + + let normalized = normalized.split_whitespace().collect::>().join(" "); + (!normalized.is_empty()).then_some(normalized) + } + + pub(crate) fn is_group_message(message: &serde_json::Value) -> bool { + message + .get("chat") + .and_then(|c| c.get("type")) + .and_then(|t| t.as_str()) + .map(|t| t == "group" || t == "supergroup") + .unwrap_or(false) + } + + pub(crate) fn is_user_allowed(&self, username: &str) -> bool { + let identity = Self::normalize_identity(username); + self.allowed_users + .read() + .map(|users| { + users + .iter() + .any(|u| u == "*" || u.eq_ignore_ascii_case(&identity)) + }) + .unwrap_or(false) + } + + pub(crate) fn is_any_user_allowed<'a, I>(&self, identities: I) -> bool + where + I: IntoIterator, + { + identities.into_iter().any(|id| self.is_user_allowed(id)) + } + + /// Check whether an approval prompt should be suppressed due to the restart-race + /// condition signature: `pairing.is_none()` (channel was constructed with a non-empty + /// allowlist) AND the runtime `allowed_users` list is currently empty. + /// + /// This happens when the replacement process reads its config allowlist, stores it in + /// `allowed_users`, but the old process has not yet shut down — Telegram redelivers + /// the update to both. The racing instance has `pairing = None` (correct — allowlist + /// was non-empty at construction) but the runtime list may briefly show as empty before + /// the config is loaded. + /// + /// Legitimate first-run pairing (`allowed_users=[]` at construction) always sets + /// `pairing = Some(...)` so it is never suppressed here. + pub(crate) fn is_race_condition_instance(&self) -> bool { + self.allowlist_is_empty() && self.pairing.is_none() + } + + /// Whether the runtime allowlist currently has no entries. A poisoned lock is + /// treated as non-empty (fail-closed) so we never widen access on a lock error. + pub(crate) fn allowlist_is_empty(&self) -> bool { + self.allowed_users + .read() + .map(|users| users.is_empty()) + .unwrap_or(false) + } + + /// Build the de-bounce key for approval prompts: `"{chat_id}:{sender}"`. + pub(crate) fn approval_debounce_key(chat_id: &str, sender: &str) -> String { + format!("{chat_id}:{sender}") + } + + /// Returns `true` if an approval prompt was already sent to this chat+sender within the + /// de-bounce window, and updates the last-sent timestamp when returning `false`. + pub(crate) fn check_and_update_approval_debounce(&self, chat_id: &str, sender: &str) -> bool { + let key = Self::approval_debounce_key(chat_id, sender); + let mut prompts = self.recent_approval_prompts.lock(); + if let Some(last_sent) = prompts.get(&key) + && last_sent.elapsed().as_secs() < APPROVAL_PROMPT_DEBOUNCE_SECS + { + return true; // still within de-bounce window + } + // Evict entries older than the de-bounce window before inserting. Anything + // past the window can never suppress again, so retaining it would let the + // map grow without bound if the bot is exposed to a public group or spam + // (review note on #1948). This caps the map to senders seen within the + // last APPROVAL_PROMPT_DEBOUNCE_SECS. + prompts + .retain(|_, last_sent| last_sent.elapsed().as_secs() < APPROVAL_PROMPT_DEBOUNCE_SECS); + prompts.insert(key, Instant::now()); + false + } + + pub(crate) async fn handle_unauthorized_message(&self, update: &serde_json::Value) { + let Some(message) = update.get("message") else { + return; + }; + + if !Self::is_supported_unauthorized_message(message) { + tracing::debug!("[telegram][approval] ignoring unsupported unauthorized update"); + return; + } + + let text = message.get("text").and_then(serde_json::Value::as_str); + + let username_opt = message + .get("from") + .and_then(|from| from.get("username")) + .and_then(serde_json::Value::as_str); + let username = username_opt.unwrap_or("unknown"); + let normalized_username = Self::normalize_identity(username); + + let sender_id = message + .get("from") + .and_then(|from| from.get("id")) + .and_then(serde_json::Value::as_i64); + let sender_id_str = sender_id.map(|id| id.to_string()); + let normalized_sender_id = sender_id_str.as_deref().map(Self::normalize_identity); + + let chat_id = message + .get("chat") + .and_then(|chat| chat.get("id")) + .and_then(serde_json::Value::as_i64) + .map(|id| id.to_string()); + + let Some(chat_id) = chat_id else { + tracing::warn!("[telegram][approval] missing chat_id in message, skipping"); + return; + }; + + let mut identities = vec![normalized_username.as_str()]; + if let Some(ref id) = normalized_sender_id { + identities.push(id.as_str()); + } + + if self.is_any_user_allowed(identities.iter().copied()) { + tracing::debug!( + chat_id, + username, + sender_id = sender_id_str.as_deref().unwrap_or("unknown"), + "[telegram][approval] message sender is allowed — no action" + ); + return; + } + + // ── Race-condition guard ───────────────────────────────────────────────── + // Signature: pairing.is_none() (channel constructed with non-empty allowlist) + // AND runtime allowed_users is currently empty. This means we are the racing + // instance spawned during a restart whose config hasn't propagated yet. + // Sending an approval prompt here would spam the allowlisted user with false + // "operator approval required" messages. Log and suppress instead. + if self.is_race_condition_instance() { + tracing::warn!( + chat_id, + username, + sender_id = sender_id_str.as_deref().unwrap_or("unknown"), + "[telegram][approval] race-condition guard: allowlist is empty at runtime \ + but channel was constructed with a non-empty allowlist (pairing=None). \ + Suppressing approval prompt — this is a restart-overlap false positive." + ); + return; + } + + // ── First-run onboarding: `/start` pairs the operator ──────────────────── + // On the self-bot-token path a blank allowlist arms `pairing = Some(..)` (a + // fresh bot is world-reachable by @username, so we must not allow-all like + // Discord). The one-time bind code, however, is only printed to core stdout + // and is invisible to a desktop operator — leaving the gate un-openable and + // every message stuck on the approval prompt (openhuman#4381). + // + // The operator's first `/start` is their explicit "I'm setting up my bot" + // signal. While pairing is still pending we treat that sender as the owner, + // add them to the allowlist, and let their subsequent messages reach the + // agent — matching the "first sender after /start" behaviour the issue + // sanctions. The guard is tight: `pairing.is_some()` excludes an + // explicitly-configured allowlist, and `allowlist_is_empty()` restricts + // onboarding to the genuine first sender — once the operator is bound the + // list is non-empty, so a later stranger's `/start` falls through to the + // normal approval prompt instead of being auto-approved. + // SECURITY (first-sender-wins TOFU): unlike `/bind ` — which + // requires the stdout secret and goes through `try_pair`'s lockout — + // `/start` onboarding trusts the first sender with no secret and no + // rate-limit. Anyone who learns the bot's `@username` before the operator + // sends the first message could claim ownership. The private-chat guard + // below removes the group attack surface (the common hijack); the residual + // window is a stale, world-reachable un-paired bot. Bounding onboarding to + // a startup time-window is a reasonable future hardening (see openhuman#4381). + if self.pairing.is_some() + && self.allowlist_is_empty() + // Private chats only: operator setup for a self-bot-token is a DM + // action. Onboarding the first `/start` sender in a *group* would let + // any member claim operator ownership (the un-paired bot may be added + // to a group mid-setup), so a group `/start` falls through to the + // normal approval prompt instead. + && !Self::is_group_message(message) + && text.map(Self::is_start_command).unwrap_or(false) + { + match Self::bindable_identity(&normalized_username, normalized_sender_id.as_deref()) { + Some(identity) => { + tracing::info!( + chat_id, + identity, + "[telegram][approval] /start onboarding: pairing first sender as operator" + ); + self.approve_and_persist_sender(&identity, &chat_id).await; + // Finish the one-time pairing flow: the operator is bound + // via /start rather than /bind , so consume the code + // here too — otherwise the stdout code stays live and a + // later sender who obtains it could still /bind themselves. + if let Some(pairing) = self.pairing.as_ref() { + pairing.invalidate_code(); + } + } + None => { + let _ = self + .send_with_outbound_intent(&SendMessage::new( + "❌ Could not identify your Telegram account from /start. Ensure your account has a username or stable user ID, then try again.", + &chat_id, + )) + .await; + } + } + return; + } + + if let Some(code) = text.and_then(Self::extract_bind_code) { + if let Some(pairing) = self.pairing.as_ref() { + match pairing.try_pair(code).await { + Ok(Some(_token)) => { + match Self::bindable_identity( + &normalized_username, + normalized_sender_id.as_deref(), + ) { + Some(identity) => { + tracing::info!( + chat_id, + identity, + "[telegram][approval] paired via bind code and allowlisted identity" + ); + self.approve_and_persist_sender(&identity, &chat_id).await; + } + None => { + let _ = self + .send_with_outbound_intent(&SendMessage::new( + "❌ Could not identify your Telegram account. Ensure your account has a username or stable user ID, then retry.", + &chat_id, + )) + .await; + } + } + } + Ok(None) => { + let _ = self + .send_with_outbound_intent(&SendMessage::new( + "❌ Invalid binding code. Ask operator for the latest code and retry.", + &chat_id, + )) + .await; + } + Err(lockout_secs) => { + let _ = self + .send_with_outbound_intent(&SendMessage::new( + format!("⏳ Too many invalid attempts. Retry in {lockout_secs}s."), + &chat_id, + )) + .await; + } + } + } else { + let _ = self + .send_with_outbound_intent(&SendMessage::new( + "ℹ️ Telegram pairing is not active. Ask operator to update allowlist in config.toml.", + &chat_id, + )) + .await; + } + return; + } + + // ── De-bounce: suppress duplicate approval prompts within the window ──────── + // Key by chat_id + sender so multiple different senders are tracked independently. + let sender_key = normalized_sender_id + .as_deref() + .unwrap_or(normalized_username.as_str()); + if self.check_and_update_approval_debounce(&chat_id, sender_key) { + tracing::debug!( + chat_id, + sender = sender_key, + "[telegram][approval] de-bounce: suppressing duplicate approval prompt \ + (sent within {}s window)", + APPROVAL_PROMPT_DEBOUNCE_SECS + ); + return; + } + + tracing::warn!( + chat_id, + username, + sender_id = sender_id_str.as_deref().unwrap_or("unknown"), + "[telegram][approval] unauthorized user; sending approval prompt. \ + Allowlist Telegram username (without '@') or numeric user ID." + ); + + // Copy depends on whether first-run pairing is armed. In pairing mode the + // operator unlocks the bot by sending `/start` (or `/bind ` if they + // have the code from the app); there is no "approve in the web UI" action for + // the self-bot-token path, so we must not point the user at one (openhuman#4381). + // + // Only advertise the `/start` onboarding hint in a private chat — in a group + // it would invite any member to claim operator ownership, matching the + // private-only onboarding gate above. + if self.pairing_code_active() && !Self::is_group_message(message) { + tracing::debug!( + chat_id, + sender = sender_key, + "[telegram][approval] pairing pending — sending /start onboarding prompt" + ); + let _ = self + .send_with_outbound_intent(&SendMessage::new( + "🔐 This bot isn't set up yet.\n\nIf you're the operator, send /start to finish connecting your bot. \ + Otherwise ask the operator to add your Telegram username (without '@') or numeric user ID to the bot's Allowed Users, then message again.\n\n\ + If the operator gave you a one-time pairing code, run `/bind `.".to_string(), + &chat_id, + )) + .await; + } else { + let _ = self + .send_with_outbound_intent(&SendMessage::new( + "🔐 This bot requires operator approval.\n\nAsk the operator to add your Telegram username (without '@') or numeric user ID to the bot's Allowed Users, then send your message again.".to_string(), + &chat_id, + )) + .await; + } + } + + /// Resolve a stable identity to allowlist for a sender: prefer the numeric user + /// ID (immutable), fall back to a real username. Returns `None` when the sender + /// has neither (`normalized_username` empty or the `"unknown"` sentinel and no id). + pub(crate) fn bindable_identity( + normalized_username: &str, + normalized_sender_id: Option<&str>, + ) -> Option { + if let Some(id) = normalized_sender_id.filter(|id| !id.is_empty()) { + return Some(id.to_string()); + } + if normalized_username.is_empty() || normalized_username == "unknown" { + return None; + } + Some(normalized_username.to_string()) + } + + /// Add `identity` to the allowlist (runtime + persisted config) and acknowledge + /// to the chat. Shared by the `/start` onboarding and `/bind ` paths so + /// both stay in lock-step on persistence and messaging. + pub(crate) async fn approve_and_persist_sender(&self, identity: &str, chat_id: &str) { + self.add_allowed_identity_runtime(identity); + // Promote into the persisted allowlist via the injected host store so the + // identity survives restarts. With no store wired, allowlisting is + // runtime-only and we still acknowledge success. + let persist_result = match &self.allowlist { + Some(store) => store.persist_allowed_identity("telegram", identity).await, + None => Ok(()), + }; + match persist_result { + Ok(()) => { + let _ = self + .send_with_outbound_intent(&SendMessage::new( + "✅ You're all set — OpenHuman is connected. Send me a message and I'll take it from here.", + chat_id, + )) + .await; + tracing::info!( + chat_id, + identity, + "[telegram][approval] allowlisted identity (runtime + persisted)" + ); + } + Err(e) => { + tracing::error!( + chat_id, + error = %e, + "[telegram][approval] failed to persist allowlist after approval" + ); + let _ = self + .send_with_outbound_intent(&SendMessage::new( + "⚠️ Connected for now, but I couldn't save it — access may be lost after a restart. Check the config file permissions.", + chat_id, + )) + .await; + } + } + } + + pub(crate) fn is_supported_unauthorized_message(message: &serde_json::Value) -> bool { + message + .get("text") + .and_then(serde_json::Value::as_str) + .is_some() + || message.get("voice").is_some() + } + + pub(crate) fn parse_update_message( + &self, + update: &serde_json::Value, + ) -> Option { + let message = update + .get("message") + .or_else(|| update.get("edited_message"))?; + + let text = message.get("text").and_then(serde_json::Value::as_str)?; + let ctx = self.parse_incoming_message_context(message, Some(text))?; + let content = match ctx.mention_text.clone() { + Some(content) => content, + None if self.mention_only && Self::is_group_message(message) => return None, + None => text.to_string(), + }; + + (!content.trim().is_empty()).then(|| self.channel_message_from_context(ctx, content)) + } + + pub(crate) async fn parse_update_message_or_voice( + &self, + update: &serde_json::Value, + ) -> Option { + let update_id = update.get("update_id").and_then(serde_json::Value::as_i64); + let message = update + .get("message") + .or_else(|| update.get("edited_message")); + let chat_id = message + .and_then(|message| message.get("chat")) + .and_then(|chat| chat.get("id")) + .and_then(serde_json::Value::as_i64); + let message_id = message + .and_then(|message| message.get("message_id")) + .and_then(serde_json::Value::as_i64); + let has_text = message + .and_then(|message| message.get("text")) + .and_then(serde_json::Value::as_str) + .is_some(); + let has_voice = message.and_then(|message| message.get("voice")).is_some(); + + tracing::debug!( + update_id = ?update_id, + chat_id = ?chat_id, + message_id = ?message_id, + has_text, + has_voice, + "[telegram:voice] parse update dispatch" + ); + + if let Some(msg) = self.parse_update_message(update) { + tracing::debug!( + update_id = ?update_id, + chat_id = ?chat_id, + message_id = ?message_id, + "[telegram:voice] selected text parser" + ); + return Some(msg); + } + + if has_voice { + tracing::debug!( + update_id = ?update_id, + chat_id = ?chat_id, + message_id = ?message_id, + "[telegram:voice] selected voice parser" + ); + } else { + tracing::debug!( + update_id = ?update_id, + chat_id = ?chat_id, + message_id = ?message_id, + "[telegram:voice] update has no supported text or voice payload" + ); + } + + let msg = self.parse_update_voice_message(update).await; + tracing::debug!( + update_id = ?update_id, + chat_id = ?chat_id, + message_id = ?message_id, + parsed = msg.is_some(), + "[telegram:voice] parse update result" + ); + msg + } + + pub(crate) fn parse_update_voice_attachment( + update: &serde_json::Value, + ) -> Option { + let voice = update + .get("message") + .or_else(|| update.get("edited_message"))? + .get("voice")?; + + let file_id = voice + .get("file_id") + .and_then(serde_json::Value::as_str)? + .trim(); + if file_id.is_empty() { + return None; + } + + Some(TelegramVoiceAttachment { + file_id: file_id.to_string(), + file_unique_id: voice + .get("file_unique_id") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + file_size: voice.get("file_size").and_then(serde_json::Value::as_u64), + mime_type: voice + .get("mime_type") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + }) + } + + pub(crate) async fn parse_update_voice_message( + &self, + update: &serde_json::Value, + ) -> Option { + let update_id = update.get("update_id").and_then(serde_json::Value::as_i64); + let message = update + .get("message") + .or_else(|| update.get("edited_message"))?; + let voice = Self::parse_update_voice_attachment(update)?; + let caption = message.get("caption").and_then(serde_json::Value::as_str); + let chat_id = message + .get("chat") + .and_then(|chat| chat.get("id")) + .and_then(serde_json::Value::as_i64); + let message_id = message + .get("message_id") + .and_then(serde_json::Value::as_i64); + + tracing::debug!( + update_id = ?update_id, + chat_id = ?chat_id, + message_id = ?message_id, + file_unique_id = voice.file_unique_id.as_deref().unwrap_or("unknown"), + has_caption = caption.is_some_and(|caption| !caption.trim().is_empty()), + "[telegram:voice] parse voice message entry" + ); + + let ctx = self.parse_incoming_message_context(message, caption)?; + tracing::debug!( + update_id = ?update_id, + chat_id = %ctx.chat_id, + message_id = ctx.message_id, + file_unique_id = voice.file_unique_id.as_deref().unwrap_or("unknown"), + has_mention_text = ctx.mention_text.is_some(), + "[telegram:voice] voice message accepted for transcription" + ); + + match self.transcribe_telegram_voice(&voice).await { + Ok(transcript) => { + let transcript = transcript.trim(); + if transcript.is_empty() { + tracing::warn!( + chat_id = %ctx.chat_id, + message_id = ctx.message_id, + file_unique_id = voice.file_unique_id.as_deref().unwrap_or("unknown"), + "[telegram:voice] inbound voice transcription returned empty text" + ); + self.send_voice_transcription_failure(&ctx).await; + return None; + } + + let mention_only_group = self.mention_only && Self::is_group_message(message); + let content = + Self::voice_message_content(transcript, caption, &ctx, mention_only_group); + + tracing::debug!( + update_id = ?update_id, + chat_id = %ctx.chat_id, + message_id = ctx.message_id, + file_unique_id = voice.file_unique_id.as_deref().unwrap_or("unknown"), + content_chars = content.chars().count(), + "[telegram:voice] voice transcript ready for channel dispatch" + ); + + Some(self.channel_message_from_context(ctx, content)) + } + Err(error) => { + let error = self.redact_bot_token(error.to_string()); + tracing::warn!( + chat_id = %ctx.chat_id, + message_id = ctx.message_id, + file_unique_id = voice.file_unique_id.as_deref().unwrap_or("unknown"), + %error, + "[telegram:voice] inbound voice transcription failed" + ); + self.send_voice_transcription_failure(&ctx).await; + None + } + } + } + + pub(crate) fn voice_message_content( + transcript: &str, + caption: Option<&str>, + ctx: &TelegramIncomingMessageContext, + mention_only_group: bool, + ) -> String { + let caption_prefix = if mention_only_group { + ctx.mention_text.as_deref() + } else { + caption + } + .map(str::trim) + .filter(|caption| !caption.is_empty()); + + match caption_prefix { + Some(prefix) => format!("{prefix}\n\n{transcript}"), + None => transcript.to_string(), + } + } + + pub(crate) fn parse_incoming_message_context( + &self, + message: &serde_json::Value, + mention_source: Option<&str>, + ) -> Option { + let username = message + .get("from") + .and_then(|from| from.get("username")) + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown") + .to_string(); + + let sender_id = message + .get("from") + .and_then(|from| from.get("id")) + .and_then(serde_json::Value::as_i64) + .map(|id| id.to_string()); + + let sender_identity = if username == "unknown" { + sender_id.clone().unwrap_or_else(|| "unknown".to_string()) + } else { + username.clone() + }; + + let mut identities = vec![username.as_str()]; + if let Some(id) = sender_id.as_deref() { + identities.push(id); + } + + if !self.is_any_user_allowed(identities.iter().copied()) { + tracing::debug!( + username = %username, + sender_id = sender_id.as_deref().unwrap_or("none"), + message_len = mention_source.map(str::len).unwrap_or_default(), + "[telegram] dropped message: sender not in allowed_users (unauthorized handler may reply)" + ); + return None; + } + + let is_group = Self::is_group_message(message); + let mention_text = if self.mention_only && is_group { + let mention_source = mention_source?; + let bot_username = self.bot_username.lock(); + if let Some(ref bot_username) = *bot_username { + if !Self::contains_bot_mention(mention_source, bot_username) { + return None; + } + Self::normalize_incoming_content(mention_source, bot_username) + } else { + return None; + } + } else { + None + }; + + let chat_id = message + .get("chat") + .and_then(|chat| chat.get("id")) + .and_then(serde_json::Value::as_i64) + .map(|id| id.to_string())?; + + let message_id = message + .get("message_id") + .and_then(serde_json::Value::as_i64) + .unwrap_or(0); + + // Extract thread/topic ID for forum support + let thread_id = message + .get("message_thread_id") + .and_then(serde_json::Value::as_i64) + .map(|id| id.to_string()); + + // reply_target: chat_id or chat_id:thread_id format + let reply_target = if let Some(tid) = thread_id { + format!("{}:{}", chat_id, tid) + } else { + chat_id.clone() + }; + + let replied_parent_message_id = message + .get("reply_to_message") + .and_then(|reply| reply.get("message_id")) + .and_then(serde_json::Value::as_i64) + .map(|id| id.to_string()); + + // Telegram "reply" targeting should point to the inbound message itself so the + // assistant response is visibly attached in chat. We still retain the inbound + // parent reference in logs for reply-context diagnostics. + tracing::debug!( + chat_id, + message_id, + reply_to_parent = replied_parent_message_id.as_deref().unwrap_or("none"), + "Telegram inbound message parsed for reply mapping" + ); + + Some(TelegramIncomingMessageContext { + sender_identity, + reply_target, + chat_id, + message_id, + mention_text, + }) + } + + pub(crate) fn channel_message_from_context( + &self, + ctx: TelegramIncomingMessageContext, + content: String, + ) -> ChannelMessage { + ChannelMessage { + id: format!("telegram_{}_{}", ctx.chat_id, ctx.message_id), + sender: ctx.sender_identity, + reply_target: ctx.reply_target, + content, + channel: "telegram".to_string(), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + thread_ts: Some(ctx.message_id.to_string()), + } + } + + pub(crate) async fn send_voice_transcription_failure( + &self, + ctx: &TelegramIncomingMessageContext, + ) { + let _ = self + .send_with_outbound_intent( + &SendMessage::new( + "Voice transcription failed. Please try again or send text.", + &ctx.reply_target, + ) + .in_thread(Some(ctx.message_id.to_string())), + ) + .await; + } + + pub(crate) async fn transcribe_telegram_voice( + &self, + voice: &TelegramVoiceAttachment, + ) -> anyhow::Result { + tracing::debug!( + file_unique_id = voice.file_unique_id.as_deref().unwrap_or("unknown"), + declared_file_size = ?voice.file_size, + mime_type = voice.mime_type.as_deref().unwrap_or("unknown"), + "[telegram:voice] transcribe entry" + ); + + if let Some(file_size) = voice.file_size + && file_size > TELEGRAM_MAX_VOICE_FILE_BYTES + { + anyhow::bail!( + "Telegram voice file too large: {file_size} bytes (max {TELEGRAM_MAX_VOICE_FILE_BYTES})" + ); + } + + let (audio_bytes, file_name, api_file_size) = self + .download_telegram_voice_file(&voice.file_id, voice.file_unique_id.as_deref()) + .await?; + tracing::debug!( + file_unique_id = voice.file_unique_id.as_deref().unwrap_or("unknown"), + downloaded_bytes = audio_bytes.len(), + api_file_size = ?api_file_size, + file_name = %file_name, + "[telegram:voice] download completed before transcription" + ); + + if let Some(file_size) = api_file_size + && file_size > TELEGRAM_MAX_VOICE_FILE_BYTES + { + anyhow::bail!( + "Telegram getFile reported voice file too large: {file_size} bytes (max {TELEGRAM_MAX_VOICE_FILE_BYTES})" + ); + } + if u64::try_from(audio_bytes.len()).unwrap_or(u64::MAX) > TELEGRAM_MAX_VOICE_FILE_BYTES { + anyhow::bail!( + "downloaded Telegram voice file too large: {} bytes (max {TELEGRAM_MAX_VOICE_FILE_BYTES})", + audio_bytes.len() + ); + } + + let audio_base64 = BASE64.encode(&audio_bytes); + let mime_type = voice.mime_type.as_deref().unwrap_or("audio/ogg"); + + let Some(transcriber) = self.transcriber.as_ref() else { + tracing::warn!( + file_unique_id = voice.file_unique_id.as_deref().unwrap_or("unknown"), + "[telegram:voice] no transcriber injected; skipping voice transcription" + ); + return Ok(String::new()); + }; + + tracing::debug!( + provider = transcriber.name(), + mime_type = %mime_type, + file_name = %file_name, + bytes = audio_bytes.len(), + "[telegram:voice] calling STT provider for inbound voice" + ); + + let request = crate::host::TranscriptionRequest { + audio_base64, + mime_type: Some(mime_type.to_string()), + file_name: Some(file_name.clone()), + language: None, + }; + let outcome = transcriber.transcribe(request).await?; + + let text = outcome.text; + tracing::debug!( + file_unique_id = voice.file_unique_id.as_deref().unwrap_or("unknown"), + transcript_chars = text.chars().count(), + "[telegram:voice] transcription completed" + ); + + Ok(text) + } + + pub(crate) fn redact_bot_token(&self, value: impl AsRef) -> String { + if self.bot_token.is_empty() { + return value.as_ref().to_string(); + } + value.as_ref().replace(&self.bot_token, "") + } + + pub(crate) async fn download_telegram_voice_file( + &self, + file_id: &str, + file_unique_id: Option<&str>, + ) -> anyhow::Result<(Vec, String, Option)> { + tracing::debug!( + file_unique_id = file_unique_id.unwrap_or("unknown"), + file_id_present = !file_id.trim().is_empty(), + "[telegram:voice:download] requesting Telegram getFile" + ); + + let resp = self + .http_client() + .post(self.api_url("getFile")) + .json(&serde_json::json!({ "file_id": file_id })) + .send() + .await + .map_err(|e| anyhow::anyhow!("{}", self.redact_bot_token(e.to_string())))?; + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + tracing::debug!( + file_unique_id = file_unique_id.unwrap_or("unknown"), + status = %status, + response_bytes = body.len(), + "[telegram:voice:download] Telegram getFile response received" + ); + + if !status.is_success() { + tracing::debug!( + file_unique_id = file_unique_id.unwrap_or("unknown"), + status = %status, + error = %self.redact_bot_token(&body), + "[telegram:voice:download] Telegram getFile failed" + ); + anyhow::bail!( + "Telegram getFile failed ({status}): {}", + self.redact_bot_token(body) + ); + } + + let payload: serde_json::Value = serde_json::from_str(&body) + .map_err(|e| anyhow::anyhow!("Telegram getFile returned invalid JSON: {e}"))?; + if !payload + .get("ok") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + let description = payload + .get("description") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown Telegram getFile error"); + tracing::debug!( + file_unique_id = file_unique_id.unwrap_or("unknown"), + description, + "[telegram:voice:download] Telegram getFile returned ok=false" + ); + anyhow::bail!("Telegram getFile returned ok=false: {description}"); + } + + let result = payload + .get("result") + .ok_or_else(|| anyhow::anyhow!("Telegram getFile response missing result"))?; + let file_path = result + .get("file_path") + .and_then(serde_json::Value::as_str) + .filter(|path| !path.trim().is_empty()) + .ok_or_else(|| anyhow::anyhow!("Telegram getFile response missing file_path"))?; + let file_size = result.get("file_size").and_then(serde_json::Value::as_u64); + if let Some(file_size) = file_size + && file_size > TELEGRAM_MAX_VOICE_FILE_BYTES + { + tracing::debug!( + file_unique_id = file_unique_id.unwrap_or("unknown"), + file_size, + max_bytes = TELEGRAM_MAX_VOICE_FILE_BYTES, + "[telegram:voice:download] Telegram getFile file_size exceeds cap" + ); + anyhow::bail!( + "Telegram getFile reported voice file too large: {file_size} bytes (max {TELEGRAM_MAX_VOICE_FILE_BYTES})" + ); + } + let file_name = file_path + .rsplit('/') + .next() + .filter(|name| !name.trim().is_empty()) + .unwrap_or("voice.ogg") + .to_string(); + tracing::debug!( + file_unique_id = file_unique_id.unwrap_or("unknown"), + file_name = %file_name, + file_size = ?file_size, + "[telegram:voice:download] Telegram getFile result parsed" + ); + + let download_url = format!("{}/file/bot{}/{}", self.api_base, self.bot_token, file_path); + tracing::debug!( + file_unique_id = file_unique_id.unwrap_or("unknown"), + file_name = %file_name, + "[telegram:voice:download] starting Telegram voice file download" + ); + let mut file_resp = self + .http_client() + .get(download_url) + .send() + .await + .map_err(|e| anyhow::anyhow!("{}", self.redact_bot_token(e.to_string())))?; + if let Some(content_length) = file_resp.content_length() + && content_length > TELEGRAM_MAX_VOICE_FILE_BYTES + { + tracing::debug!( + file_unique_id = file_unique_id.unwrap_or("unknown"), + content_length, + max_bytes = TELEGRAM_MAX_VOICE_FILE_BYTES, + "[telegram:voice:download] voice download Content-Length exceeds cap" + ); + anyhow::bail!( + "Telegram voice download too large: {content_length} bytes (max {TELEGRAM_MAX_VOICE_FILE_BYTES})" + ); + } + tracing::debug!( + file_unique_id = file_unique_id.unwrap_or("unknown"), + content_length = ?file_resp.content_length(), + "[telegram:voice:download] voice download headers received" + ); + + let status = file_resp.status(); + if !status.is_success() { + let body = file_resp.text().await.unwrap_or_default(); + tracing::debug!( + file_unique_id = file_unique_id.unwrap_or("unknown"), + status = %status, + error = %self.redact_bot_token(&body), + "[telegram:voice:download] Telegram voice download failed" + ); + anyhow::bail!( + "Telegram voice download failed ({status}): {}", + self.redact_bot_token(body) + ); + } + + let mut bytes = Vec::new(); + while let Some(chunk) = file_resp + .chunk() + .await + .map_err(|e| anyhow::anyhow!("{}", self.redact_bot_token(e.to_string())))? + { + Self::append_telegram_voice_download_chunk( + &mut bytes, + &chunk, + TELEGRAM_MAX_VOICE_FILE_BYTES, + )?; + tracing::debug!( + file_unique_id = file_unique_id.unwrap_or("unknown"), + file_name = %file_name, + chunk_bytes = chunk.len(), + downloaded_bytes = bytes.len(), + "[telegram:voice:download] received voice file chunk" + ); + } + tracing::debug!( + file_unique_id = file_unique_id.unwrap_or("unknown"), + file_name = %file_name, + downloaded_bytes = bytes.len(), + "[telegram:voice:download] completed Telegram voice file download" + ); + + Ok((bytes, file_name, file_size)) + } + + pub(crate) fn append_telegram_voice_download_chunk( + bytes: &mut Vec, + chunk: &[u8], + max_bytes: u64, + ) -> anyhow::Result<()> { + let next_len = bytes + .len() + .checked_add(chunk.len()) + .ok_or_else(|| anyhow::anyhow!("Telegram voice download size overflow"))?; + if u64::try_from(next_len).unwrap_or(u64::MAX) > max_bytes { + anyhow::bail!("Telegram voice download too large: {next_len} bytes (max {max_bytes})"); + } + + bytes.extend_from_slice(chunk); + Ok(()) + } + + pub(crate) fn parse_update_reaction( + &self, + update: &serde_json::Value, + ) -> Option { + let reaction = update.get("message_reaction")?; + + let chat_id = reaction + .get("chat") + .and_then(|chat| chat.get("id")) + .and_then(serde_json::Value::as_i64) + .map(|id| id.to_string())?; + let message_id = reaction + .get("message_id") + .and_then(serde_json::Value::as_i64) + .map(|id| id.to_string())?; + let actor = reaction + .get("user") + .and_then(|user| user.get("username")) + .and_then(serde_json::Value::as_str) + .map(ToString::to_string) + .or_else(|| { + reaction + .get("user") + .and_then(|user| user.get("id")) + .and_then(serde_json::Value::as_i64) + .map(|id| id.to_string()) + }) + .unwrap_or_else(|| "unknown".to_string()); + + let user_id = reaction + .get("user") + .and_then(|user| user.get("id")) + .and_then(serde_json::Value::as_i64) + .map(|id| id.to_string()); + + let actor_allowed = self.is_user_allowed(&actor); + let user_id_allowed = user_id + .as_deref() + .is_some_and(|id| self.is_user_allowed(id)); + + if !(actor_allowed || user_id_allowed) { + tracing::debug!( + actor, + message_id, + "Telegram reaction ignored: actor is not allowlisted" + ); + return None; + } + + let emoji = reaction + .get("new_reaction") + .and_then(serde_json::Value::as_array) + .and_then(|arr| { + arr.iter().find_map(|entry| { + entry + .get("emoji") + .and_then(serde_json::Value::as_str) + .map(ToString::to_string) + }) + })?; + + Some(TelegramReactionEvent { + sender: actor, + reply_target: chat_id, + target_message_id: message_id, + emoji, + }) + } +} diff --git a/src/providers/telegram/channel_send.rs b/src/providers/telegram/channel_send.rs new file mode 100644 index 0000000..98f4b60 --- /dev/null +++ b/src/providers/telegram/channel_send.rs @@ -0,0 +1,701 @@ +//! Telegram channel — outbound message sending: text chunking, media uploads, reaction sending, +//! and attachment dispatch. + +use super::attachments::{TelegramAttachment, TelegramAttachmentKind, is_http_url}; +use super::channel_types::TelegramChannel; +use super::text::split_message_for_telegram; +use reqwest::multipart::{Form, Part}; +use std::path::Path; +use std::time::Duration; + +impl TelegramChannel { + pub(crate) fn parse_reaction_marker(content: &str) -> (String, Option) { + // Marker format at the start of the message: [REACTION:😀] or [REACTION:😀|12345] + // The marker may be followed by a text reply: [REACTION:👍] Great point! + // Returns (remaining_text, Some(marker_inner)) or (original, None). + let trimmed = content.trim(); + let Some(rest) = trimmed.strip_prefix("[REACTION:") else { + return (content.to_string(), None); + }; + let Some(close_pos) = rest.find(']') else { + return (content.to_string(), None); + }; + let inner = rest[..close_pos].trim(); + if inner.is_empty() { + return (String::new(), None); + } + let remaining = rest[close_pos + 1..].trim().to_string(); + (remaining, Some(inner.to_string())) + } + + pub(crate) async fn send_message_reaction( + &self, + chat_id: &str, + message_id: i64, + emoji: &str, + ) -> anyhow::Result { + let emoji = emoji.trim(); + if emoji.is_empty() { + return Ok(false); + } + + let body = serde_json::json!({ + "chat_id": chat_id, + "message_id": message_id, + "reaction": [ + { + "type": "emoji", + "emoji": emoji + } + ], + "is_big": false + }); + let resp = self + .http_client() + .post(self.api_url("setMessageReaction")) + .json(&body) + .send() + .await?; + if resp.status().is_success() { + self.publish_reaction_sent(chat_id, message_id, emoji, true) + .await; + tracing::info!(chat_id, message_id, emoji, "Telegram reaction sent"); + return Ok(true); + } + + let status = resp.status(); + let err = resp.text().await.unwrap_or_default(); + tracing::warn!( + chat_id, + message_id, + emoji, + status = ?status, + error = err, + "Telegram reaction not applied; continuing without failure" + ); + self.publish_reaction_sent(chat_id, message_id, emoji, false) + .await; + Ok(false) + } + + /// Best-effort fan-out of a reaction-sent event onto the host event sink. + /// No-op when no [`crate::host::EventSink`] is injected. + async fn publish_reaction_sent( + &self, + chat_id: &str, + message_id: i64, + emoji: &str, + success: bool, + ) { + if let Some(events) = &self.events { + events + .publish( + "channel", + "reaction_sent", + serde_json::json!({ + "channel": "telegram", + "target_message_id": format!("telegram_{chat_id}_{message_id}"), + "emoji": emoji, + "success": success, + }), + ) + .await + .ok(); + } + } + + pub(crate) async fn send_text_chunks( + &self, + message: &str, + chat_id: &str, + thread_id: Option<&str>, + reply_to_message_id: Option, + ) -> anyhow::Result<()> { + let chunks = split_message_for_telegram(message); + + for (index, chunk) in chunks.iter().enumerate() { + let text = if chunks.len() > 1 { + if index == 0 { + format!("{chunk}\n\n(continues...)") + } else if index == chunks.len() - 1 { + format!("(continued)\n\n{chunk}") + } else { + format!("(continued)\n\n{chunk}\n\n(continues...)") + } + } else { + chunk.to_string() + }; + + let mut markdown_body = serde_json::json!({ + "chat_id": chat_id, + "text": text, + "parse_mode": "Markdown" + }); + + // Add message_thread_id for forum topic support + if let Some(tid) = thread_id { + markdown_body["message_thread_id"] = serde_json::Value::String(tid.to_string()); + } + if index == 0 + && let Some(parent_id) = reply_to_message_id + { + markdown_body["reply_to_message_id"] = serde_json::Value::from(parent_id); + } + + let markdown_resp = self + .http_client() + .post(self.api_url("sendMessage")) + .json(&markdown_body) + .send() + .await?; + + if markdown_resp.status().is_success() { + if index < chunks.len() - 1 { + tokio::time::sleep(Duration::from_millis(100)).await; + } + continue; + } + + let markdown_status = markdown_resp.status(); + let markdown_err = markdown_resp.text().await.unwrap_or_default(); + tracing::warn!( + status = ?markdown_status, + "Telegram sendMessage with Markdown failed; retrying without parse_mode" + ); + + let mut plain_body = serde_json::json!({ + "chat_id": chat_id, + "text": text, + }); + + // Add message_thread_id for forum topic support + if let Some(tid) = thread_id { + plain_body["message_thread_id"] = serde_json::Value::String(tid.to_string()); + } + if index == 0 + && let Some(parent_id) = reply_to_message_id + { + plain_body["reply_to_message_id"] = serde_json::Value::from(parent_id); + } + let plain_resp = self + .http_client() + .post(self.api_url("sendMessage")) + .json(&plain_body) + .send() + .await?; + + if !plain_resp.status().is_success() { + let plain_status = plain_resp.status(); + let plain_err = plain_resp.text().await.unwrap_or_default(); + anyhow::bail!( + "Telegram sendMessage failed (markdown {}: {}; plain {}: {})", + markdown_status, + markdown_err, + plain_status, + plain_err + ); + } + + if index < chunks.len() - 1 { + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + + Ok(()) + } + + async fn send_media_by_url( + &self, + method: &str, + media_field: &str, + chat_id: &str, + thread_id: Option<&str>, + url: &str, + caption: Option<&str>, + ) -> anyhow::Result<()> { + let mut body = serde_json::json!({ + "chat_id": chat_id, + }); + body[media_field] = serde_json::Value::String(url.to_string()); + + if let Some(tid) = thread_id { + body["message_thread_id"] = serde_json::Value::String(tid.to_string()); + } + + if let Some(cap) = caption { + body["caption"] = serde_json::Value::String(cap.to_string()); + } + + let resp = self + .http_client() + .post(self.api_url(method)) + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await?; + anyhow::bail!("Telegram {method} by URL failed: {err}"); + } + + tracing::info!("Telegram {method} sent to {chat_id}: {url}"); + Ok(()) + } + + pub(crate) async fn send_attachment( + &self, + chat_id: &str, + thread_id: Option<&str>, + attachment: &TelegramAttachment, + ) -> anyhow::Result<()> { + let target = attachment.target.trim(); + + if is_http_url(target) { + return match attachment.kind { + TelegramAttachmentKind::Image => { + self.send_photo_by_url(chat_id, thread_id, target, None) + .await + } + TelegramAttachmentKind::Document => { + self.send_document_by_url(chat_id, thread_id, target, None) + .await + } + TelegramAttachmentKind::Video => { + self.send_video_by_url(chat_id, thread_id, target, None) + .await + } + TelegramAttachmentKind::Audio => { + self.send_audio_by_url(chat_id, thread_id, target, None) + .await + } + TelegramAttachmentKind::Voice => { + self.send_voice_by_url(chat_id, thread_id, target, None) + .await + } + }; + } + + let path = Path::new(target); + if !path.exists() { + anyhow::bail!("Telegram attachment path not found: {target}"); + } + + match attachment.kind { + TelegramAttachmentKind::Image => self.send_photo(chat_id, thread_id, path, None).await, + TelegramAttachmentKind::Document => { + self.send_document(chat_id, thread_id, path, None).await + } + TelegramAttachmentKind::Video => self.send_video(chat_id, thread_id, path, None).await, + TelegramAttachmentKind::Audio => self.send_audio(chat_id, thread_id, path, None).await, + TelegramAttachmentKind::Voice => self.send_voice(chat_id, thread_id, path, None).await, + } + } + + /// Send a document/file to a Telegram chat + pub async fn send_document( + &self, + chat_id: &str, + thread_id: Option<&str>, + file_path: &Path, + caption: Option<&str>, + ) -> anyhow::Result<()> { + let file_name = file_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file"); + + let file_bytes = tokio::fs::read(file_path).await?; + let part = Part::bytes(file_bytes).file_name(file_name.to_string()); + + let mut form = Form::new() + .text("chat_id", chat_id.to_string()) + .part("document", part); + + if let Some(tid) = thread_id { + form = form.text("message_thread_id", tid.to_string()); + } + + if let Some(cap) = caption { + form = form.text("caption", cap.to_string()); + } + + let resp = self + .http_client() + .post(self.api_url("sendDocument")) + .multipart(form) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await?; + anyhow::bail!("Telegram sendDocument failed: {err}"); + } + + tracing::info!("Telegram document sent to {chat_id}: {file_name}"); + Ok(()) + } + + /// Send a document from bytes (in-memory) to a Telegram chat + pub async fn send_document_bytes( + &self, + chat_id: &str, + thread_id: Option<&str>, + file_bytes: Vec, + file_name: &str, + caption: Option<&str>, + ) -> anyhow::Result<()> { + let part = Part::bytes(file_bytes).file_name(file_name.to_string()); + + let mut form = Form::new() + .text("chat_id", chat_id.to_string()) + .part("document", part); + + if let Some(tid) = thread_id { + form = form.text("message_thread_id", tid.to_string()); + } + + if let Some(cap) = caption { + form = form.text("caption", cap.to_string()); + } + + let resp = self + .http_client() + .post(self.api_url("sendDocument")) + .multipart(form) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await?; + anyhow::bail!("Telegram sendDocument failed: {err}"); + } + + tracing::info!("Telegram document sent to {chat_id}: {file_name}"); + Ok(()) + } + + /// Send a photo to a Telegram chat + pub async fn send_photo( + &self, + chat_id: &str, + thread_id: Option<&str>, + file_path: &Path, + caption: Option<&str>, + ) -> anyhow::Result<()> { + let file_name = file_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("photo.jpg"); + + let file_bytes = tokio::fs::read(file_path).await?; + let part = Part::bytes(file_bytes).file_name(file_name.to_string()); + + let mut form = Form::new() + .text("chat_id", chat_id.to_string()) + .part("photo", part); + + if let Some(tid) = thread_id { + form = form.text("message_thread_id", tid.to_string()); + } + + if let Some(cap) = caption { + form = form.text("caption", cap.to_string()); + } + + let resp = self + .http_client() + .post(self.api_url("sendPhoto")) + .multipart(form) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await?; + anyhow::bail!("Telegram sendPhoto failed: {err}"); + } + + tracing::info!("Telegram photo sent to {chat_id}: {file_name}"); + Ok(()) + } + + /// Send a photo from bytes (in-memory) to a Telegram chat + pub async fn send_photo_bytes( + &self, + chat_id: &str, + thread_id: Option<&str>, + file_bytes: Vec, + file_name: &str, + caption: Option<&str>, + ) -> anyhow::Result<()> { + let part = Part::bytes(file_bytes).file_name(file_name.to_string()); + + let mut form = Form::new() + .text("chat_id", chat_id.to_string()) + .part("photo", part); + + if let Some(tid) = thread_id { + form = form.text("message_thread_id", tid.to_string()); + } + + if let Some(cap) = caption { + form = form.text("caption", cap.to_string()); + } + + let resp = self + .http_client() + .post(self.api_url("sendPhoto")) + .multipart(form) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await?; + anyhow::bail!("Telegram sendPhoto failed: {err}"); + } + + tracing::info!("Telegram photo sent to {chat_id}: {file_name}"); + Ok(()) + } + + /// Send a video to a Telegram chat + pub async fn send_video( + &self, + chat_id: &str, + thread_id: Option<&str>, + file_path: &Path, + caption: Option<&str>, + ) -> anyhow::Result<()> { + let file_name = file_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("video.mp4"); + + let file_bytes = tokio::fs::read(file_path).await?; + let part = Part::bytes(file_bytes).file_name(file_name.to_string()); + + let mut form = Form::new() + .text("chat_id", chat_id.to_string()) + .part("video", part); + + if let Some(tid) = thread_id { + form = form.text("message_thread_id", tid.to_string()); + } + + if let Some(cap) = caption { + form = form.text("caption", cap.to_string()); + } + + let resp = self + .http_client() + .post(self.api_url("sendVideo")) + .multipart(form) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await?; + anyhow::bail!("Telegram sendVideo failed: {err}"); + } + + tracing::info!("Telegram video sent to {chat_id}: {file_name}"); + Ok(()) + } + + /// Send an audio file to a Telegram chat + pub async fn send_audio( + &self, + chat_id: &str, + thread_id: Option<&str>, + file_path: &Path, + caption: Option<&str>, + ) -> anyhow::Result<()> { + let file_name = file_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("audio.mp3"); + + let file_bytes = tokio::fs::read(file_path).await?; + let part = Part::bytes(file_bytes).file_name(file_name.to_string()); + + let mut form = Form::new() + .text("chat_id", chat_id.to_string()) + .part("audio", part); + + if let Some(tid) = thread_id { + form = form.text("message_thread_id", tid.to_string()); + } + + if let Some(cap) = caption { + form = form.text("caption", cap.to_string()); + } + + let resp = self + .http_client() + .post(self.api_url("sendAudio")) + .multipart(form) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await?; + anyhow::bail!("Telegram sendAudio failed: {err}"); + } + + tracing::info!("Telegram audio sent to {chat_id}: {file_name}"); + Ok(()) + } + + /// Send a voice message to a Telegram chat + pub async fn send_voice( + &self, + chat_id: &str, + thread_id: Option<&str>, + file_path: &Path, + caption: Option<&str>, + ) -> anyhow::Result<()> { + let file_name = file_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("voice.ogg"); + + let file_bytes = tokio::fs::read(file_path).await?; + let part = Part::bytes(file_bytes).file_name(file_name.to_string()); + + let mut form = Form::new() + .text("chat_id", chat_id.to_string()) + .part("voice", part); + + if let Some(tid) = thread_id { + form = form.text("message_thread_id", tid.to_string()); + } + + if let Some(cap) = caption { + form = form.text("caption", cap.to_string()); + } + + let resp = self + .http_client() + .post(self.api_url("sendVoice")) + .multipart(form) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await?; + anyhow::bail!("Telegram sendVoice failed: {err}"); + } + + tracing::info!("Telegram voice sent to {chat_id}: {file_name}"); + Ok(()) + } + + /// Send a file by URL (Telegram will download it) + pub async fn send_document_by_url( + &self, + chat_id: &str, + thread_id: Option<&str>, + url: &str, + caption: Option<&str>, + ) -> anyhow::Result<()> { + let mut body = serde_json::json!({ + "chat_id": chat_id, + "document": url + }); + + if let Some(tid) = thread_id { + body["message_thread_id"] = serde_json::Value::String(tid.to_string()); + } + + if let Some(cap) = caption { + body["caption"] = serde_json::Value::String(cap.to_string()); + } + + let resp = self + .http_client() + .post(self.api_url("sendDocument")) + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await?; + anyhow::bail!("Telegram sendDocument by URL failed: {err}"); + } + + tracing::info!("Telegram document (URL) sent to {chat_id}: {url}"); + Ok(()) + } + + /// Send a photo by URL (Telegram will download it) + pub async fn send_photo_by_url( + &self, + chat_id: &str, + thread_id: Option<&str>, + url: &str, + caption: Option<&str>, + ) -> anyhow::Result<()> { + let mut body = serde_json::json!({ + "chat_id": chat_id, + "photo": url + }); + + if let Some(tid) = thread_id { + body["message_thread_id"] = serde_json::Value::String(tid.to_string()); + } + + if let Some(cap) = caption { + body["caption"] = serde_json::Value::String(cap.to_string()); + } + + let resp = self + .http_client() + .post(self.api_url("sendPhoto")) + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await?; + anyhow::bail!("Telegram sendPhoto by URL failed: {err}"); + } + + tracing::info!("Telegram photo (URL) sent to {chat_id}: {url}"); + Ok(()) + } + + /// Send a video by URL (Telegram will download it) + pub async fn send_video_by_url( + &self, + chat_id: &str, + thread_id: Option<&str>, + url: &str, + caption: Option<&str>, + ) -> anyhow::Result<()> { + self.send_media_by_url("sendVideo", "video", chat_id, thread_id, url, caption) + .await + } + + /// Send an audio file by URL (Telegram will download it) + pub async fn send_audio_by_url( + &self, + chat_id: &str, + thread_id: Option<&str>, + url: &str, + caption: Option<&str>, + ) -> anyhow::Result<()> { + self.send_media_by_url("sendAudio", "audio", chat_id, thread_id, url, caption) + .await + } + + /// Send a voice message by URL (Telegram will download it) + pub async fn send_voice_by_url( + &self, + chat_id: &str, + thread_id: Option<&str>, + url: &str, + caption: Option<&str>, + ) -> anyhow::Result<()> { + self.send_media_by_url("sendVoice", "voice", chat_id, thread_id, url, caption) + .await + } +} diff --git a/src/providers/telegram/channel_tests.rs b/src/providers/telegram/channel_tests.rs new file mode 100644 index 0000000..cabf197 --- /dev/null +++ b/src/providers/telegram/channel_tests.rs @@ -0,0 +1,2163 @@ +use super::super::attachments::{ + TelegramAttachmentKind, infer_attachment_kind_from_target, parse_attachment_markers, + parse_path_only_attachment, +}; +use super::super::text::{ + TELEGRAM_MAX_MESSAGE_LENGTH, split_message_for_telegram, strip_tool_call_tags, +}; +use super::TelegramChannel; +use crate::config::StreamMode; +use crate::traits::{Channel, SendMessage}; +use std::path::Path; +use std::time::Duration; + +#[test] +fn telegram_channel_name() { + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); + assert_eq!(ch.name(), "telegram"); +} + +#[test] +fn proactive_target_uses_configured_chat_id() { + // Unset by default ⇒ proactive routing skips Telegram (#3712 parity). + let default = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); + assert_eq!(default.proactive_target(), None); + + // Configured chat_id ⇒ recipient-less proactive sends have a target. + let with_chat = TelegramChannel::new("fake-token".into(), vec!["*".into()], false) + .with_chat_id(Some("12345".into())); + assert_eq!(with_chat.proactive_target(), Some("12345".to_string())); + + // Whitespace-only chat_id is normalized to unset. + let blank = TelegramChannel::new("fake-token".into(), vec!["*".into()], false) + .with_chat_id(Some(" ".into())); + assert_eq!(blank.proactive_target(), None); + + // Explicit None passed to the builder stays unset. + let none = + TelegramChannel::new("fake-token".into(), vec!["*".into()], false).with_chat_id(None); + assert_eq!(none.proactive_target(), None); +} + +#[test] +fn typing_handle_starts_as_none() { + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); + let guard = ch.typing_handle.lock(); + assert!(guard.is_none()); +} + +#[tokio::test] +async fn stop_typing_clears_handle() { + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); + + // Manually insert a dummy handle + { + let mut guard = ch.typing_handle.lock(); + *guard = Some(super::TelegramTypingTask { + recipient: "123".to_string(), + handle: tokio::spawn(async { + tokio::time::sleep(Duration::from_secs(60)).await; + }), + }); + } + + // stop_typing should abort and clear + ch.stop_typing("123").await.unwrap(); + + let guard = ch.typing_handle.lock(); + assert!(guard.is_none()); +} + +#[tokio::test] +async fn start_typing_replaces_previous_handle() { + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); + + // Insert a dummy handle first + { + let mut guard = ch.typing_handle.lock(); + *guard = Some(super::TelegramTypingTask { + recipient: "123".to_string(), + handle: tokio::spawn(async { + tokio::time::sleep(Duration::from_secs(60)).await; + }), + }); + } + + // start_typing should abort the old handle and set a new one + let _ = ch.start_typing("123").await; + + let guard = ch.typing_handle.lock(); + assert!(guard.is_some()); +} + +#[test] +fn supports_draft_updates_respects_stream_mode() { + let off = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); + assert!(!off.supports_draft_updates()); + + let partial = TelegramChannel::new("fake-token".into(), vec!["*".into()], false) + .with_streaming(StreamMode::Partial, 750, true); + assert!(partial.supports_draft_updates()); + assert_eq!(partial.draft_update_interval_ms, 750); + assert!(partial.silent_streaming); +} + +#[tokio::test] +async fn send_draft_returns_none_when_stream_mode_off() { + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); + let id = ch + .send_draft(&SendMessage::new("draft", "123")) + .await + .unwrap(); + assert!(id.is_none()); +} + +#[tokio::test] +async fn update_draft_rate_limit_short_circuits_network() { + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false).with_streaming( + StreamMode::Partial, + 60_000, + true, + ); + ch.last_draft_edit + .lock() + .insert("123".to_string(), std::time::Instant::now()); + + let result = ch.update_draft("123", "42", "delta text").await; + assert!(result.is_ok()); +} + +#[tokio::test] +async fn update_draft_utf8_truncation_is_safe_for_multibyte_text() { + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false).with_streaming( + StreamMode::Partial, + 0, + true, + ); + let long_emoji_text = "😀".repeat(TELEGRAM_MAX_MESSAGE_LENGTH + 20); + + // Invalid message_id returns early after building display_text. + // This asserts truncation never panics on UTF-8 boundaries. + let result = ch + .update_draft("123", "not-a-number", &long_emoji_text) + .await; + assert!(result.is_ok()); +} + +#[tokio::test] +async fn finalize_draft_invalid_message_id_falls_back_to_chunk_send() { + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false).with_streaming( + StreamMode::Partial, + 0, + true, + ); + let long_text = "a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH + 64); + + // For oversized text + invalid draft message_id, finalize_draft should + // fall back to chunked send instead of returning early. + let result = ch + .finalize_draft("123", "not-a-number", &long_text, None) + .await; + assert!(result.is_err()); +} + +#[test] +fn telegram_api_url() { + let ch = TelegramChannel::new("123:ABC".into(), vec![], false); + assert_eq!( + ch.api_url("getMe"), + "https://api.telegram.org/bot123:ABC/getMe" + ); +} + +// ── OPENHUMAN_TELEGRAM_API_BASE override tests ────────────────────────────── +// +// Exercises `resolve_api_base` directly as a pure function so the test does +// not mutate `std::env`. Mutating env here races with other parallel tests in +// this module that construct `TelegramChannel::new()` and expect the default +// api.telegram.org base. +#[test] +fn telegram_api_base_default_when_unset() { + use super::super::channel_core::resolve_api_base; + assert_eq!(resolve_api_base(None), "https://api.telegram.org"); + assert_eq!( + resolve_api_base(Some("".to_string())), + "https://api.telegram.org" + ); + assert_eq!( + resolve_api_base(Some(" ".to_string())), + "https://api.telegram.org" + ); +} + +#[test] +fn telegram_api_base_custom_value() { + use super::super::channel_core::resolve_api_base; + assert_eq!( + resolve_api_base(Some("http://127.0.0.1:18473".to_string())), + "http://127.0.0.1:18473" + ); +} + +#[test] +fn telegram_api_base_trailing_slash_stripped() { + use super::super::channel_core::resolve_api_base; + assert_eq!( + resolve_api_base(Some("http://127.0.0.1:18473/".to_string())), + "http://127.0.0.1:18473" + ); + assert_eq!( + resolve_api_base(Some("http://example.com///".to_string())), + "http://example.com" + ); +} + +#[test] +fn telegram_user_allowed_wildcard() { + let ch = TelegramChannel::new("t".into(), vec!["*".into()], false); + assert!(ch.is_user_allowed("anyone")); +} + +#[test] +fn telegram_user_allowed_specific() { + let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "bob".into()], false); + assert!(ch.is_user_allowed("alice")); + assert!(!ch.is_user_allowed("eve")); +} + +#[test] +fn telegram_user_allowed_with_at_prefix_in_config() { + let ch = TelegramChannel::new("t".into(), vec!["@alice".into()], false); + assert!(ch.is_user_allowed("alice")); +} + +#[test] +fn telegram_user_denied_empty() { + let ch = TelegramChannel::new("t".into(), vec![], false); + assert!(!ch.is_user_allowed("anyone")); +} + +#[test] +fn telegram_user_exact_match_not_substring() { + let ch = TelegramChannel::new("t".into(), vec!["alice".into()], false); + assert!(!ch.is_user_allowed("alice_bot")); + assert!(!ch.is_user_allowed("alic")); + assert!(!ch.is_user_allowed("malice")); +} + +#[test] +fn telegram_user_empty_string_denied() { + let ch = TelegramChannel::new("t".into(), vec!["alice".into()], false); + assert!(!ch.is_user_allowed("")); +} + +#[test] +fn telegram_user_case_insensitive() { + let ch = TelegramChannel::new("t".into(), vec!["Alice".into()], false); + assert!(ch.is_user_allowed("Alice")); + assert!(ch.is_user_allowed("alice")); + assert!(ch.is_user_allowed("ALICE")); +} + +#[test] +fn telegram_wildcard_with_specific_users() { + let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "*".into()], false); + assert!(ch.is_user_allowed("alice")); + assert!(ch.is_user_allowed("bob")); + assert!(ch.is_user_allowed("anyone")); +} + +#[test] +fn telegram_user_allowed_by_numeric_id_identity() { + let ch = TelegramChannel::new("t".into(), vec!["123456789".into()], false); + assert!(ch.is_any_user_allowed(["unknown", "123456789"])); +} + +#[test] +fn telegram_user_denied_when_none_of_identities_match() { + let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "987654321".into()], false); + assert!(!ch.is_any_user_allowed(["unknown", "123456789"])); +} + +#[tokio::test] +async fn telegram_pairing_enabled_with_empty_allowlist() { + let ch = TelegramChannel::new("t".into(), vec![], false); + assert!(ch.pairing_code_active()); +} + +#[tokio::test] +async fn telegram_pairing_disabled_with_nonempty_allowlist() { + let ch = TelegramChannel::new("t".into(), vec!["alice".into()], false); + assert!(!ch.pairing_code_active()); +} + +#[test] +fn telegram_extract_bind_code_plain_command() { + assert_eq!( + TelegramChannel::extract_bind_code("/bind 123456"), + Some("123456") + ); +} + +#[test] +fn telegram_extract_bind_code_supports_bot_mention() { + assert_eq!( + TelegramChannel::extract_bind_code("/bind@openhuman_bot 654321"), + Some("654321") + ); +} + +#[test] +fn telegram_extract_bind_code_rejects_invalid_forms() { + assert_eq!(TelegramChannel::extract_bind_code("/bind"), None); + assert_eq!(TelegramChannel::extract_bind_code("/start"), None); +} + +#[test] +fn telegram_is_start_command_accepts_valid_forms() { + assert!(TelegramChannel::is_start_command("/start")); + // Addressed to a specific bot in a group. + assert!(TelegramChannel::is_start_command("/start@openhuman_bot")); + // Deep-link / payload after the command (still a /start). + assert!(TelegramChannel::is_start_command("/start deadbeef")); + // Leading whitespace is tolerated (split_whitespace skips it). + assert!(TelegramChannel::is_start_command(" /start")); +} + +#[test] +fn telegram_is_start_command_rejects_non_start() { + assert!(!TelegramChannel::is_start_command("/bind 123")); + assert!(!TelegramChannel::is_start_command("start")); + assert!(!TelegramChannel::is_start_command("hello")); + assert!(!TelegramChannel::is_start_command("")); + // Must be the whole command token, not a prefix. + assert!(!TelegramChannel::is_start_command("/started")); +} + +#[test] +fn telegram_bindable_identity_prefers_numeric_id() { + // Numeric id is immutable, so it wins over a mutable username. + assert_eq!( + TelegramChannel::bindable_identity("alice", Some("123456789")), + Some("123456789".to_string()) + ); +} + +#[test] +fn telegram_bindable_identity_falls_back_to_username() { + assert_eq!( + TelegramChannel::bindable_identity("alice", None), + Some("alice".to_string()) + ); + // An empty id string is ignored, not used as the identity. + assert_eq!( + TelegramChannel::bindable_identity("alice", Some("")), + Some("alice".to_string()) + ); +} + +#[test] +fn telegram_bindable_identity_none_when_unidentified() { + assert_eq!(TelegramChannel::bindable_identity("unknown", None), None); + assert_eq!(TelegramChannel::bindable_identity("", None), None); +} + +#[test] +fn telegram_allowlist_is_empty_tracks_runtime_state() { + // Fresh pairing-mode channel starts empty ... + let ch = TelegramChannel::new("t".into(), vec![], false); + assert!(ch.allowlist_is_empty()); + // ... and flips to non-empty once the first sender is approved at runtime, + // which is what closes the `/start` first-run onboarding window. + ch.add_allowed_identity_runtime("123456789"); + assert!(!ch.allowlist_is_empty()); + + // A channel constructed with an explicit allowlist is never "empty". + let configured = TelegramChannel::new("t".into(), vec!["alice".into()], false); + assert!(!configured.allowlist_is_empty()); +} + +#[test] +fn parse_attachment_markers_extracts_multiple_types() { + let message = "Here are files [IMAGE:/tmp/a.png] and [DOCUMENT:https://example.com/a.pdf]"; + let (cleaned, attachments) = parse_attachment_markers(message); + + assert_eq!(cleaned, "Here are files and"); + assert_eq!(attachments.len(), 2); + assert_eq!(attachments[0].kind, TelegramAttachmentKind::Image); + assert_eq!(attachments[0].target, "/tmp/a.png"); + assert_eq!(attachments[1].kind, TelegramAttachmentKind::Document); + assert_eq!(attachments[1].target, "https://example.com/a.pdf"); +} + +#[test] +fn parse_attachment_markers_keeps_invalid_markers_in_text() { + let message = "Report [UNKNOWN:/tmp/a.bin]"; + let (cleaned, attachments) = parse_attachment_markers(message); + + assert_eq!(cleaned, "Report [UNKNOWN:/tmp/a.bin]"); + assert!(attachments.is_empty()); +} + +#[test] +fn parse_path_only_attachment_detects_existing_file() { + let dir = tempfile::tempdir().unwrap(); + let image_path = dir.path().join("snap.png"); + std::fs::write(&image_path, b"fake-png").unwrap(); + + let parsed = parse_path_only_attachment(image_path.to_string_lossy().as_ref()) + .expect("expected attachment"); + + assert_eq!(parsed.kind, TelegramAttachmentKind::Image); + assert_eq!(parsed.target, image_path.to_string_lossy()); +} + +#[test] +fn parse_path_only_attachment_rejects_sentence_text() { + assert!(parse_path_only_attachment("Screenshot saved to /tmp/snap.png").is_none()); +} + +#[test] +fn infer_attachment_kind_from_target_detects_document_extension() { + assert_eq!( + infer_attachment_kind_from_target("https://example.com/files/specs.pdf?download=1"), + Some(TelegramAttachmentKind::Document) + ); +} + +#[test] +fn parse_update_message_uses_chat_id_as_reply_target() { + let ch = TelegramChannel::new("token".into(), vec!["*".into()], false); + let update = serde_json::json!({ + "update_id": 1, + "message": { + "message_id": 33, + "text": "hello", + "from": { + "id": 555, + "username": "alice" + }, + "chat": { + "id": -100_200_300 + } + } + }); + + let msg = ch + .parse_update_message(&update) + .expect("message should parse"); + + assert_eq!(msg.sender, "alice"); + assert_eq!(msg.reply_target, "-100200300"); + assert_eq!(msg.content, "hello"); + assert_eq!(msg.id, "telegram_-100200300_33"); +} + +#[test] +fn parse_update_message_allows_numeric_id_without_username() { + let ch = TelegramChannel::new("token".into(), vec!["555".into()], false); + let update = serde_json::json!({ + "update_id": 2, + "message": { + "message_id": 9, + "text": "ping", + "from": { + "id": 555 + }, + "chat": { + "id": 12345 + } + } + }); + + let msg = ch + .parse_update_message(&update) + .expect("numeric allowlist should pass"); + + assert_eq!(msg.sender, "555"); + assert_eq!(msg.reply_target, "12345"); +} + +#[test] +fn parse_update_message_extracts_thread_id_for_forum_topic() { + let ch = TelegramChannel::new("token".into(), vec!["*".into()], false); + let update = serde_json::json!({ + "update_id": 3, + "message": { + "message_id": 42, + "text": "hello from topic", + "from": { + "id": 555, + "username": "alice" + }, + "chat": { + "id": -100_200_300 + }, + "message_thread_id": 789 + } + }); + + let msg = ch + .parse_update_message(&update) + .expect("message with thread_id should parse"); + + assert_eq!(msg.sender, "alice"); + assert_eq!(msg.reply_target, "-100200300:789"); + assert_eq!(msg.content, "hello from topic"); + assert_eq!(msg.id, "telegram_-100200300_42"); +} + +#[test] +fn parse_update_message_sets_thread_ts_to_current_message_id_for_outbound_reply() { + let ch = TelegramChannel::new("token".into(), vec!["*".into()], false); + let update = serde_json::json!({ + "update_id": 4, + "message": { + "message_id": 99, + "text": "reply body", + "from": { + "id": 555, + "username": "alice" + }, + "chat": { + "id": 12345 + }, + "reply_to_message": { + "message_id": 88 + } + } + }); + + let msg = ch + .parse_update_message(&update) + .expect("message should parse"); + assert_eq!(msg.thread_ts.as_deref(), Some("99")); + assert_eq!(msg.reply_target, "12345"); +} + +#[test] +fn parse_update_voice_attachment_extracts_telegram_voice_metadata() { + let update = serde_json::json!({ + "update_id": 6, + "message": { + "message_id": 101, + "voice": { + "file_id": "AwACAgUAAxkBAAIB", + "file_unique_id": "AgADabc", + "duration": 3, + "mime_type": "audio/ogg", + "file_size": 4096 + }, + "from": { + "id": 555, + "username": "alice" + }, + "chat": { + "id": 12345 + } + } + }); + + let voice = TelegramChannel::parse_update_voice_attachment(&update) + .expect("voice attachment should parse"); + + assert_eq!(voice.file_id, "AwACAgUAAxkBAAIB"); + assert_eq!(voice.file_unique_id.as_deref(), Some("AgADabc")); + assert_eq!(voice.mime_type.as_deref(), Some("audio/ogg")); + assert_eq!(voice.file_size, Some(4096)); +} + +#[test] +fn inbound_voice_context_preserves_reply_mapping_for_transcript_dispatch() { + let ch = TelegramChannel::new("token".into(), vec!["*".into()], false); + let update = serde_json::json!({ + "update_id": 7, + "message": { + "message_id": 202, + "voice": { + "file_id": "voice-file-id" + }, + "from": { + "id": 555, + "username": "alice" + }, + "chat": { + "id": -100200300 + }, + "message_thread_id": 42, + "reply_to_message": { + "message_id": 199 + } + } + }); + + let ctx = ch + .parse_incoming_message_context(&update["message"], None) + .expect("authorized voice message context should parse"); + let msg = ch.channel_message_from_context(ctx, "transcribed voice text".to_string()); + + assert_eq!(msg.sender, "alice"); + assert_eq!(msg.reply_target, "-100200300:42"); + assert_eq!(msg.thread_ts.as_deref(), Some("202")); + assert_eq!(msg.id, "telegram_-100200300_202"); + assert_eq!(msg.content, "transcribed voice text"); +} + +#[test] +fn inbound_voice_context_mention_only_group_requires_caption_mention() { + let ch = TelegramChannel::new("token".into(), vec!["*".into()], true); + *ch.bot_username.lock() = Some("mybot".to_string()); + let update = serde_json::json!({ + "update_id": 8, + "message": { + "message_id": 303, + "caption": "@mybot summarize this", + "voice": { + "file_id": "voice-file-id" + }, + "from": { + "id": 555, + "username": "alice" + }, + "chat": { + "id": -100200300, + "type": "supergroup" + } + } + }); + + let ctx = ch + .parse_incoming_message_context(&update["message"], Some("@mybot summarize this")) + .expect("caption mention should allow voice in mention-only group"); + + assert_eq!(ctx.mention_text.as_deref(), Some("summarize this")); + + let no_caption_update = serde_json::json!({ + "update_id": 9, + "message": { + "message_id": 304, + "voice": { + "file_id": "voice-file-id" + }, + "from": { + "id": 555, + "username": "alice" + }, + "chat": { + "id": -100200300, + "type": "supergroup" + } + } + }); + + assert!( + ch.parse_incoming_message_context(&no_caption_update["message"], None) + .is_none() + ); +} + +#[test] +fn inbound_voice_content_preserves_caption_outside_mention_only_groups() { + let ch = TelegramChannel::new("token".into(), vec!["*".into()], false); + let update = serde_json::json!({ + "message": { + "message_id": 305, + "caption": "summarize this", + "voice": { + "file_id": "voice-file-id" + }, + "from": { + "id": 555, + "username": "alice" + }, + "chat": { + "id": 12345, + "type": "private" + } + } + }); + + let ctx = ch + .parse_incoming_message_context(&update["message"], Some("summarize this")) + .expect("authorized voice message context should parse"); + let content = TelegramChannel::voice_message_content( + "transcribed voice", + Some("summarize this"), + &ctx, + false, + ); + + assert_eq!(content, "summarize this\n\ntranscribed voice"); +} + +#[test] +fn inbound_voice_content_uses_normalized_caption_in_mention_only_groups() { + let ch = TelegramChannel::new("token".into(), vec!["*".into()], true); + *ch.bot_username.lock() = Some("mybot".to_string()); + let update = serde_json::json!({ + "message": { + "message_id": 306, + "caption": "@mybot summarize this", + "voice": { + "file_id": "voice-file-id" + }, + "from": { + "id": 555, + "username": "alice" + }, + "chat": { + "id": -100200300, + "type": "supergroup" + } + } + }); + + let ctx = ch + .parse_incoming_message_context(&update["message"], Some("@mybot summarize this")) + .expect("caption mention should allow voice in mention-only group"); + let content = TelegramChannel::voice_message_content( + "transcribed voice", + Some("@mybot summarize this"), + &ctx, + true, + ); + + assert_eq!(content, "summarize this\n\ntranscribed voice"); +} + +#[test] +fn unauthorized_approval_only_supports_text_or_voice_messages() { + let text = serde_json::json!({ "text": "hello" }); + let voice = serde_json::json!({ "voice": { "file_id": "voice-file-id" } }); + let sticker = serde_json::json!({ "sticker": { "file_id": "sticker-file-id" } }); + let photo = serde_json::json!({ "photo": [{ "file_id": "photo-file-id" }] }); + + assert!(TelegramChannel::is_supported_unauthorized_message(&text)); + assert!(TelegramChannel::is_supported_unauthorized_message(&voice)); + assert!(!TelegramChannel::is_supported_unauthorized_message( + &sticker + )); + assert!(!TelegramChannel::is_supported_unauthorized_message(&photo)); +} + +#[test] +fn telegram_error_redaction_hides_bot_token() { + let ch = TelegramChannel::new("123456:ABCSECRET".into(), vec!["*".into()], false); + let redacted = ch.redact_bot_token( + "request failed for https://api.telegram.org/bot123456:ABCSECRET/getFile", + ); + + assert!(!redacted.contains("123456:ABCSECRET")); + assert!(redacted.contains("")); +} + +#[tokio::test] +async fn download_telegram_voice_file_uses_get_file_path_and_downloads_bytes() { + use wiremock::matchers::{body_json, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + let mut ch = TelegramChannel::new("token".into(), vec!["*".into()], false); + ch.api_base = server.uri(); + + Mock::given(method("POST")) + .and(path("/bottoken/getFile")) + .and(body_json(serde_json::json!({ "file_id": "voice-file-id" }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "ok": true, + "result": { + "file_path": "voice/file_1.ogg", + "file_size": 4 + } + }))) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/file/bottoken/voice/file_1.ogg")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(vec![1, 2, 3, 4])) + .mount(&server) + .await; + + let (bytes, file_name, file_size) = ch + .download_telegram_voice_file("voice-file-id", Some("voice-unique-id")) + .await + .expect("mocked Telegram voice download should succeed"); + + assert_eq!(bytes, vec![1, 2, 3, 4]); + assert_eq!(file_name, "file_1.ogg"); + assert_eq!(file_size, Some(4)); +} + +#[test] +fn append_telegram_voice_download_chunk_enforces_size_cap() { + let mut bytes = vec![1, 2, 3]; + + TelegramChannel::append_telegram_voice_download_chunk(&mut bytes, &[4], 4) + .expect("chunk within cap should append"); + assert_eq!(bytes, vec![1, 2, 3, 4]); + + let error = TelegramChannel::append_telegram_voice_download_chunk(&mut bytes, &[5], 4) + .expect_err("chunk beyond cap should fail"); + assert!( + error + .to_string() + .contains("Telegram voice download too large") + ); + assert_eq!(bytes, vec![1, 2, 3, 4]); +} + +#[test] +fn parse_update_reaction_extracts_actor_target_and_emoji() { + let ch = TelegramChannel::new("token".into(), vec!["*".into()], false); + let update = serde_json::json!({ + "update_id": 5, + "message_reaction": { + "chat": { + "id": -100200300 + }, + "message_id": 123, + "user": { + "id": 777, + "username": "alice" + }, + "old_reaction": [], + "new_reaction": [ + { "type": "emoji", "emoji": "🔥" } + ] + } + }); + + let reaction = ch + .parse_update_reaction(&update) + .expect("reaction should parse"); + assert_eq!(reaction.sender, "alice"); + assert_eq!(reaction.reply_target, "-100200300"); + assert_eq!(reaction.target_message_id, "123"); + assert_eq!(reaction.emoji, "🔥"); +} + +#[test] +fn parse_reaction_marker_supports_optional_target_id() { + let (content, marker) = TelegramChannel::parse_reaction_marker("[REACTION:✅|321]"); + assert_eq!(content, ""); + assert_eq!(marker.as_deref(), Some("✅|321")); + + let (content, marker) = TelegramChannel::parse_reaction_marker("hello"); + assert_eq!(content, "hello"); + assert!(marker.is_none()); +} + +#[test] +fn parse_reaction_marker_allows_inline_reply_text() { + // Bot can react AND reply in one turn: [REACTION:👍] reply text + let (content, marker) = + TelegramChannel::parse_reaction_marker("[REACTION:👍] That's a great point!"); + assert_eq!(content, "That's a great point!"); + assert_eq!(marker.as_deref(), Some("👍")); + + // Explicit target id + inline text + let (content, marker) = + TelegramChannel::parse_reaction_marker("[REACTION:🔥|999] Here's my full reply."); + assert_eq!(content, "Here's my full reply."); + assert_eq!(marker.as_deref(), Some("🔥|999")); + + // Reaction only (no trailing text) still works + let (content, marker) = TelegramChannel::parse_reaction_marker("[REACTION:🤔]"); + assert_eq!(content, ""); + assert_eq!(marker.as_deref(), Some("🤔")); +} + +#[test] +fn update_tracking_dedupes_and_skips_stale_updates() { + let ch = TelegramChannel::new("token".into(), vec!["*".into()], false); + assert!(ch.track_update_id(10)); + assert!( + !ch.track_update_id(10), + "duplicate update should be skipped" + ); + assert!( + !ch.track_update_id(9), + "stale out-of-order update should be skipped" + ); + assert!(ch.track_update_id(11)); +} + +// ── File sending API URL tests ────────────────────────────────── + +#[test] +fn telegram_api_url_send_document() { + let ch = TelegramChannel::new("123:ABC".into(), vec![], false); + assert_eq!( + ch.api_url("sendDocument"), + "https://api.telegram.org/bot123:ABC/sendDocument" + ); +} + +#[test] +fn telegram_api_url_send_photo() { + let ch = TelegramChannel::new("123:ABC".into(), vec![], false); + assert_eq!( + ch.api_url("sendPhoto"), + "https://api.telegram.org/bot123:ABC/sendPhoto" + ); +} + +#[test] +fn telegram_api_url_send_video() { + let ch = TelegramChannel::new("123:ABC".into(), vec![], false); + assert_eq!( + ch.api_url("sendVideo"), + "https://api.telegram.org/bot123:ABC/sendVideo" + ); +} + +#[test] +fn telegram_api_url_send_audio() { + let ch = TelegramChannel::new("123:ABC".into(), vec![], false); + assert_eq!( + ch.api_url("sendAudio"), + "https://api.telegram.org/bot123:ABC/sendAudio" + ); +} + +#[test] +fn telegram_api_url_send_voice() { + let ch = TelegramChannel::new("123:ABC".into(), vec![], false); + assert_eq!( + ch.api_url("sendVoice"), + "https://api.telegram.org/bot123:ABC/sendVoice" + ); +} + +// ── File sending integration tests (with mock server) ────────── + +#[tokio::test] +async fn telegram_send_document_bytes_builds_correct_form() { + // This test verifies the method doesn't panic and handles bytes correctly + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); + let file_bytes = b"Hello, this is a test file content".to_vec(); + + // The actual API call will fail (no real server), but we verify the method exists + // and handles the input correctly up to the network call + let result = ch + .send_document_bytes("123456", None, file_bytes, "test.txt", Some("Test caption")) + .await; + + // Should fail with network error, not a panic or type error + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + // Error should be network-related, not a code bug + assert!( + err.contains("error") || err.contains("failed") || err.contains("connect"), + "Expected network error, got: {err}" + ); +} + +#[tokio::test] +async fn telegram_send_photo_bytes_builds_correct_form() { + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); + // Minimal valid PNG header bytes + let file_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + + let result = ch + .send_photo_bytes("123456", None, file_bytes, "test.png", None) + .await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn telegram_send_document_by_url_builds_correct_json() { + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); + + let result = ch + .send_document_by_url( + "123456", + None, + "https://example.com/file.pdf", + Some("PDF doc"), + ) + .await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn telegram_send_photo_by_url_builds_correct_json() { + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); + + let result = ch + .send_photo_by_url("123456", None, "https://example.com/image.jpg", None) + .await; + + assert!(result.is_err()); +} + +// ── File path handling tests ──────────────────────────────────── + +#[tokio::test] +async fn telegram_send_document_nonexistent_file() { + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); + let path = Path::new("/nonexistent/path/to/file.txt"); + + let result = ch.send_document("123456", None, path, None).await; + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + // Should fail with file not found error + assert!( + err.contains("No such file") || err.contains("not found") || err.contains("os error"), + "Expected file not found error, got: {err}" + ); +} + +#[tokio::test] +async fn telegram_send_photo_nonexistent_file() { + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); + let path = Path::new("/nonexistent/path/to/photo.jpg"); + + let result = ch.send_photo("123456", None, path, None).await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn telegram_send_video_nonexistent_file() { + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); + let path = Path::new("/nonexistent/path/to/video.mp4"); + + let result = ch.send_video("123456", None, path, None).await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn telegram_send_audio_nonexistent_file() { + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); + let path = Path::new("/nonexistent/path/to/audio.mp3"); + + let result = ch.send_audio("123456", None, path, None).await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn telegram_send_voice_nonexistent_file() { + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); + let path = Path::new("/nonexistent/path/to/voice.ogg"); + + let result = ch.send_voice("123456", None, path, None).await; + + assert!(result.is_err()); +} + +// ── Message splitting tests ───────────────────────────────────── + +#[test] +fn telegram_split_short_message() { + let msg = "Hello, world!"; + let chunks = split_message_for_telegram(msg); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0], msg); +} + +#[test] +fn telegram_split_exact_limit() { + let msg = "a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH); + let chunks = split_message_for_telegram(&msg); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].len(), TELEGRAM_MAX_MESSAGE_LENGTH); +} + +#[test] +fn telegram_split_over_limit() { + let msg = "a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH + 100); + let chunks = split_message_for_telegram(&msg); + assert_eq!(chunks.len(), 2); + assert!(chunks[0].len() <= TELEGRAM_MAX_MESSAGE_LENGTH); + assert!(chunks[1].len() <= TELEGRAM_MAX_MESSAGE_LENGTH); +} + +#[test] +fn telegram_split_at_word_boundary() { + let msg = format!( + "{} more text here", + "word ".repeat(TELEGRAM_MAX_MESSAGE_LENGTH / 5) + ); + let chunks = split_message_for_telegram(&msg); + assert!(chunks.len() >= 2); + // First chunk should end with a complete word (space at the end) + for chunk in &chunks[..chunks.len() - 1] { + assert!(chunk.len() <= TELEGRAM_MAX_MESSAGE_LENGTH); + } +} + +#[test] +fn telegram_split_at_newline() { + let text_block = "Line of text\n".repeat(TELEGRAM_MAX_MESSAGE_LENGTH / 13 + 1); + let chunks = split_message_for_telegram(&text_block); + assert!(chunks.len() >= 2); + for chunk in chunks { + assert!(chunk.len() <= TELEGRAM_MAX_MESSAGE_LENGTH); + } +} + +#[test] +fn telegram_split_preserves_content() { + let msg = "test ".repeat(TELEGRAM_MAX_MESSAGE_LENGTH / 5 + 100); + let chunks = split_message_for_telegram(&msg); + let rejoined = chunks.join(""); + assert_eq!(rejoined, msg); +} + +#[test] +fn telegram_split_empty_message() { + let chunks = split_message_for_telegram(""); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0], ""); +} + +#[test] +fn telegram_split_very_long_message() { + let msg = "x".repeat(TELEGRAM_MAX_MESSAGE_LENGTH * 3); + let chunks = split_message_for_telegram(&msg); + assert!(chunks.len() >= 3); + for chunk in chunks { + assert!(chunk.len() <= TELEGRAM_MAX_MESSAGE_LENGTH); + } +} + +// ── Caption handling tests ────────────────────────────────────── + +#[tokio::test] +async fn telegram_send_document_bytes_with_caption() { + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); + let file_bytes = b"test content".to_vec(); + + // With caption + let result = ch + .send_document_bytes( + "123456", + None, + file_bytes.clone(), + "test.txt", + Some("My caption"), + ) + .await; + assert!(result.is_err()); // Network error expected + + // Without caption + let result = ch + .send_document_bytes("123456", None, file_bytes, "test.txt", None) + .await; + assert!(result.is_err()); // Network error expected +} + +#[tokio::test] +async fn telegram_send_photo_bytes_with_caption() { + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); + let file_bytes = vec![0x89, 0x50, 0x4E, 0x47]; + + // With caption + let result = ch + .send_photo_bytes( + "123456", + None, + file_bytes.clone(), + "test.png", + Some("Photo caption"), + ) + .await; + assert!(result.is_err()); + + // Without caption + let result = ch + .send_photo_bytes("123456", None, file_bytes, "test.png", None) + .await; + assert!(result.is_err()); +} + +// ── Empty/edge case tests ─────────────────────────────────────── + +#[tokio::test] +async fn telegram_send_document_bytes_empty_file() { + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); + let file_bytes: Vec = vec![]; + + let result = ch + .send_document_bytes("123456", None, file_bytes, "empty.txt", None) + .await; + + // Should not panic, will fail at API level + assert!(result.is_err()); +} + +#[tokio::test] +async fn telegram_send_document_bytes_empty_filename() { + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); + let file_bytes = b"content".to_vec(); + + let result = ch + .send_document_bytes("123456", None, file_bytes, "", None) + .await; + + // Should not panic + assert!(result.is_err()); +} + +#[tokio::test] +async fn telegram_send_document_bytes_empty_chat_id() { + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); + let file_bytes = b"content".to_vec(); + + let result = ch + .send_document_bytes("", None, file_bytes, "test.txt", None) + .await; + + // Should not panic + assert!(result.is_err()); +} + +// ── Message ID edge cases ───────────────────────────────────── + +#[test] +fn telegram_message_id_format_includes_chat_and_message_id() { + // Verify that message IDs follow the format: telegram_{chat_id}_{message_id} + let chat_id = "123456"; + let message_id = 789; + let expected_id = format!("telegram_{chat_id}_{message_id}"); + assert_eq!(expected_id, "telegram_123456_789"); +} + +#[test] +fn telegram_message_id_is_deterministic() { + // Same chat_id + same message_id = same ID (prevents duplicates after restart) + let chat_id = "123456"; + let message_id = 789; + let id1 = format!("telegram_{chat_id}_{message_id}"); + let id2 = format!("telegram_{chat_id}_{message_id}"); + assert_eq!(id1, id2); +} + +#[test] +fn telegram_message_id_different_message_different_id() { + // Different message IDs produce different IDs + let chat_id = "123456"; + let id1 = format!("telegram_{chat_id}_789"); + let id2 = format!("telegram_{chat_id}_790"); + assert_ne!(id1, id2); +} + +#[test] +fn telegram_message_id_different_chat_different_id() { + // Different chats produce different IDs even with same message_id + let message_id = 789; + let id1 = format!("telegram_123456_{message_id}"); + let id2 = format!("telegram_789012_{message_id}"); + assert_ne!(id1, id2); +} + +#[test] +fn telegram_message_id_no_uuid_randomness() { + // Verify format doesn't contain random UUID components + let chat_id = "123456"; + let message_id = 789; + let id = format!("telegram_{chat_id}_{message_id}"); + assert!(!id.contains('-')); // No UUID dashes + assert!(id.starts_with("telegram_")); +} + +#[test] +fn telegram_message_id_handles_zero_message_id() { + // Edge case: message_id can be 0 (fallback/missing case) + let chat_id = "123456"; + let message_id = 0; + let id = format!("telegram_{chat_id}_{message_id}"); + assert_eq!(id, "telegram_123456_0"); +} + +// ── Tool call tag stripping tests ─────────────────────────────────── + +#[test] +fn strip_tool_call_tags_removes_standard_tags() { + let input = "Hello {\"name\":\"shell\",\"arguments\":{\"command\":\"ls\"}} world"; + let result = strip_tool_call_tags(input); + assert_eq!(result, "Hello world"); +} + +#[test] +fn strip_tool_call_tags_removes_alias_tags() { + let input = + "Hello {\"name\":\"shell\",\"arguments\":{\"command\":\"ls\"}} world"; + let result = strip_tool_call_tags(input); + assert_eq!(result, "Hello world"); +} + +#[test] +fn strip_tool_call_tags_removes_dash_tags() { + let input = "Hello {\"name\":\"shell\",\"arguments\":{\"command\":\"ls\"}} world"; + let result = strip_tool_call_tags(input); + assert_eq!(result, "Hello world"); +} + +#[test] +fn strip_tool_call_tags_removes_tool_call_tags() { + let input = "Hello {\"name\":\"shell\",\"arguments\":{\"command\":\"ls\"}} world"; + let result = strip_tool_call_tags(input); + assert_eq!(result, "Hello world"); +} + +#[test] +fn strip_tool_call_tags_removes_invoke_tags() { + let input = + "Hello {\"name\":\"shell\",\"arguments\":{\"command\":\"date\"}} world"; + let result = strip_tool_call_tags(input); + assert_eq!(result, "Hello world"); +} + +#[test] +fn strip_tool_call_tags_handles_multiple_tags() { + let input = "Start a middle b end"; + let result = strip_tool_call_tags(input); + assert_eq!(result, "Start middle end"); +} + +#[test] +fn strip_tool_call_tags_handles_mixed_tags() { + let input = "A a B b C c D"; + let result = strip_tool_call_tags(input); + assert_eq!(result, "A B C D"); +} + +#[test] +fn strip_tool_call_tags_preserves_normal_text() { + let input = "Hello world! This is a test."; + let result = strip_tool_call_tags(input); + assert_eq!(result, "Hello world! This is a test."); +} + +#[test] +fn strip_tool_call_tags_handles_unclosed_tags() { + let input = "Hello world"; + let result = strip_tool_call_tags(input); + assert_eq!(result, "Hello world"); +} + +#[test] +fn strip_tool_call_tags_handles_unclosed_tool_call_with_json() { + let input = "Status:\n\n{\"name\":\"shell\",\"arguments\":{\"command\":\"uptime\"}}"; + let result = strip_tool_call_tags(input); + assert_eq!(result, "Status:"); +} + +#[test] +fn strip_tool_call_tags_handles_mismatched_close_tag() { + let input = + "{\"name\":\"shell\",\"arguments\":{\"command\":\"uptime\"}}"; + let result = strip_tool_call_tags(input); + assert_eq!(result, ""); +} + +#[test] +fn strip_tool_call_tags_cleans_extra_newlines() { + let input = "Hello\n\n\ntest\n\n\n\nworld"; + let result = strip_tool_call_tags(input); + assert_eq!(result, "Hello\n\nworld"); +} + +#[test] +fn strip_tool_call_tags_handles_empty_input() { + let input = ""; + let result = strip_tool_call_tags(input); + assert_eq!(result, ""); +} + +#[test] +fn strip_tool_call_tags_handles_only_tags() { + let input = "{\"name\":\"test\"}"; + let result = strip_tool_call_tags(input); + assert_eq!(result, ""); +} + +#[test] +fn telegram_contains_bot_mention_finds_mention() { + assert!(TelegramChannel::contains_bot_mention( + "Hello @mybot", + "mybot" + )); + assert!(TelegramChannel::contains_bot_mention( + "@mybot help", + "mybot" + )); + assert!(TelegramChannel::contains_bot_mention( + "Hey @mybot how are you?", + "mybot" + )); + assert!(TelegramChannel::contains_bot_mention( + "Hello @MyBot, can you help?", + "mybot" + )); +} + +#[test] +fn telegram_contains_bot_mention_no_false_positives() { + assert!(!TelegramChannel::contains_bot_mention( + "Hello @otherbot", + "mybot" + )); + assert!(!TelegramChannel::contains_bot_mention( + "Hello mybot", + "mybot" + )); + assert!(!TelegramChannel::contains_bot_mention( + "Hello @mybot2", + "mybot" + )); + assert!(!TelegramChannel::contains_bot_mention("", "mybot")); +} + +#[test] +fn telegram_normalize_incoming_content_strips_mention() { + let result = TelegramChannel::normalize_incoming_content("@mybot hello", "mybot"); + assert_eq!(result, Some("hello".to_string())); +} + +#[test] +fn telegram_normalize_incoming_content_handles_multiple_mentions() { + let result = TelegramChannel::normalize_incoming_content("@mybot @mybot test", "mybot"); + assert_eq!(result, Some("test".to_string())); +} + +#[test] +fn telegram_normalize_incoming_content_returns_none_for_empty() { + let result = TelegramChannel::normalize_incoming_content("@mybot", "mybot"); + assert_eq!(result, None); +} + +#[test] +fn parse_update_message_mention_only_group_requires_exact_mention() { + let ch = TelegramChannel::new("token".into(), vec!["*".into()], true); + { + let mut cache = ch.bot_username.lock(); + *cache = Some("mybot".to_string()); + } + + let update = serde_json::json!({ + "update_id": 10, + "message": { + "message_id": 44, + "text": "hello @mybot2", + "from": { + "id": 555, + "username": "alice" + }, + "chat": { + "id": -100_200_300, + "type": "group" + } + } + }); + + assert!(ch.parse_update_message(&update).is_none()); +} + +#[test] +fn parse_update_message_mention_only_group_strips_mention_and_drops_empty() { + let ch = TelegramChannel::new("token".into(), vec!["*".into()], true); + { + let mut cache = ch.bot_username.lock(); + *cache = Some("mybot".to_string()); + } + + let update = serde_json::json!({ + "update_id": 11, + "message": { + "message_id": 45, + "text": "Hi @MyBot status please", + "from": { + "id": 555, + "username": "alice" + }, + "chat": { + "id": -100_200_300, + "type": "group" + } + } + }); + + let parsed = ch + .parse_update_message(&update) + .expect("mention should parse"); + assert_eq!(parsed.content, "Hi status please"); + + let empty_update = serde_json::json!({ + "update_id": 12, + "message": { + "message_id": 46, + "text": "@mybot", + "from": { + "id": 555, + "username": "alice" + }, + "chat": { + "id": -100_200_300, + "type": "group" + } + } + }); + + assert!(ch.parse_update_message(&empty_update).is_none()); +} + +#[test] +fn telegram_is_group_message_detects_groups() { + let group_msg = serde_json::json!({ + "chat": { "type": "group" } + }); + assert!(TelegramChannel::is_group_message(&group_msg)); + + let supergroup_msg = serde_json::json!({ + "chat": { "type": "supergroup" } + }); + assert!(TelegramChannel::is_group_message(&supergroup_msg)); + + let private_msg = serde_json::json!({ + "chat": { "type": "private" } + }); + assert!(!TelegramChannel::is_group_message(&private_msg)); +} + +#[test] +fn telegram_mention_only_enabled_by_config() { + let ch = TelegramChannel::new("token".into(), vec!["*".into()], true); + assert!(ch.mention_only); + + let ch_disabled = TelegramChannel::new("token".into(), vec!["*".into()], false); + assert!(!ch_disabled.mention_only); +} + +// ───────────────────────────────────────────────────────────────────── +// TG6: Channel platform limit edge cases for Telegram (4096 char limit) +// Prevents: Pattern 6 — issues #574, #499 +// ───────────────────────────────────────────────────────────────────── + +#[test] +fn telegram_split_code_block_at_boundary() { + let mut msg = String::new(); + msg.push_str("```python\n"); + msg.push_str(&"x".repeat(4085)); + msg.push_str("\n```\nMore text after code block"); + let parts = split_message_for_telegram(&msg); + assert!( + parts.len() >= 2, + "code block spanning boundary should split" + ); + for part in &parts { + assert!( + part.len() <= TELEGRAM_MAX_MESSAGE_LENGTH, + "each part must be <= {TELEGRAM_MAX_MESSAGE_LENGTH}, got {}", + part.len() + ); + } +} + +#[test] +fn telegram_split_single_long_word() { + let long_word = "a".repeat(5000); + let parts = split_message_for_telegram(&long_word); + assert!(parts.len() >= 2, "word exceeding limit must be split"); + for part in &parts { + assert!( + part.len() <= TELEGRAM_MAX_MESSAGE_LENGTH, + "hard-split part must be <= {TELEGRAM_MAX_MESSAGE_LENGTH}, got {}", + part.len() + ); + } + let reassembled: String = parts.join(""); + assert_eq!(reassembled, long_word); +} + +#[test] +fn telegram_split_exactly_at_limit_no_split() { + let msg = "a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH); + let parts = split_message_for_telegram(&msg); + assert_eq!(parts.len(), 1, "message exactly at limit should not split"); +} + +#[test] +fn telegram_split_one_over_limit() { + let msg = "a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH + 1); + let parts = split_message_for_telegram(&msg); + assert!(parts.len() >= 2, "message 1 char over limit must split"); +} + +#[test] +fn telegram_split_many_short_lines() { + let msg: String = (0..1000).map(|i| format!("line {i}\n")).collect(); + let parts = split_message_for_telegram(&msg); + for part in &parts { + assert!( + part.len() <= TELEGRAM_MAX_MESSAGE_LENGTH, + "short-line batch must be <= limit" + ); + } +} + +#[test] +fn telegram_split_only_whitespace() { + let msg = " \n\n\t "; + let parts = split_message_for_telegram(msg); + assert!(parts.len() <= 1); +} + +#[test] +fn telegram_split_emoji_at_boundary() { + let mut msg = "a".repeat(4094); + msg.push_str("🎉🎊"); // 4096 chars total + let parts = split_message_for_telegram(&msg); + for part in &parts { + // The function splits on character count, not byte count + assert!( + part.chars().count() <= TELEGRAM_MAX_MESSAGE_LENGTH, + "emoji boundary split must respect limit" + ); + } +} + +#[test] +fn telegram_split_consecutive_newlines() { + let mut msg = "a".repeat(4090); + msg.push_str("\n\n\n\n\n\n"); + msg.push_str(&"b".repeat(100)); + let parts = split_message_for_telegram(&msg); + for part in &parts { + assert!(part.len() <= TELEGRAM_MAX_MESSAGE_LENGTH); + } +} + +// ── Reaction allowlist tests ──────────────────────────────────── + +#[test] +fn parse_update_reaction_returns_none_for_unlisted_actor() { + // Only "alice" is allowed; "mallory" should be rejected. + let ch = TelegramChannel::new("token".into(), vec!["alice".into()], false); + let update = serde_json::json!({ + "update_id": 50, + "message_reaction": { + "chat": { "id": 100 }, + "message_id": 1, + "user": { "id": 999, "username": "mallory" }, + "old_reaction": [], + "new_reaction": [{ "type": "emoji", "emoji": "👍" }] + } + }); + assert!( + ch.parse_update_reaction(&update).is_none(), + "reaction from non-allowlisted actor must be ignored" + ); +} + +#[test] +fn parse_update_reaction_returns_none_when_new_reaction_is_empty() { + // Removing a reaction (new_reaction is empty) should yield None. + let ch = TelegramChannel::new("token".into(), vec!["*".into()], false); + let update = serde_json::json!({ + "update_id": 51, + "message_reaction": { + "chat": { "id": 100 }, + "message_id": 55, + "user": { "id": 777, "username": "alice" }, + "old_reaction": [{ "type": "emoji", "emoji": "👍" }], + "new_reaction": [] + } + }); + assert!( + ch.parse_update_reaction(&update).is_none(), + "reaction removal (empty new_reaction) must be ignored" + ); +} + +#[test] +fn parse_update_reaction_falls_back_to_user_id_when_username_absent() { + // No "username" field; allowlist uses numeric user id. + let ch = TelegramChannel::new("token".into(), vec!["99999".into()], false); + let update = serde_json::json!({ + "update_id": 52, + "message_reaction": { + "chat": { "id": 200 }, + "message_id": 77, + "user": { "id": 99999 }, + "old_reaction": [], + "new_reaction": [{ "type": "emoji", "emoji": "❤️" }] + } + }); + let reaction = ch + .parse_update_reaction(&update) + .expect("user_id in allowlist should be accepted"); + assert_eq!(reaction.sender, "99999"); + assert_eq!(reaction.emoji, "❤️"); + assert_eq!(reaction.target_message_id, "77"); +} + +// ── Reaction marker parsing edge cases ───────────────────────── + +#[test] +fn parse_reaction_marker_plain_emoji_without_pipe_has_no_explicit_target() { + // [REACTION:👍] — no pipe separator, no explicit target message id. + let (content, marker) = TelegramChannel::parse_reaction_marker("[REACTION:👍]"); + assert_eq!( + content, "", + "content after stripping marker should be empty" + ); + assert_eq!( + marker.as_deref(), + Some("👍"), + "marker should be the emoji without pipe" + ); +} + +#[test] +fn parse_reaction_marker_empty_inner_produces_no_marker() { + // [REACTION:] — empty inner, no valid emoji. + let (content, marker) = TelegramChannel::parse_reaction_marker("[REACTION:]"); + assert_eq!( + content, "", + "content should be empty string for empty marker" + ); + assert!( + marker.is_none(), + "empty inner must not produce a reaction marker" + ); +} + +#[test] +fn parse_reaction_marker_non_marker_text_is_unchanged() { + let input = "here is your answer"; + let (content, marker) = TelegramChannel::parse_reaction_marker(input); + assert_eq!(content, input); + assert!(marker.is_none()); +} + +// ── Typing body construction tests ───────────────────────────── + +#[test] +fn typing_body_for_plain_chat_contains_no_thread_field() { + let body = TelegramChannel::typing_body_for_recipient("99999"); + assert_eq!(body["chat_id"].as_str(), Some("99999")); + assert_eq!(body["action"].as_str(), Some("typing")); + // No message_thread_id for plain chats + assert!( + body.get("message_thread_id").is_none() || body["message_thread_id"].is_null(), + "plain chat should not include message_thread_id" + ); +} + +#[test] +fn typing_body_for_forum_topic_includes_message_thread_id() { + let body = TelegramChannel::typing_body_for_recipient("99999:42"); + assert_eq!(body["chat_id"].as_str(), Some("99999")); + assert_eq!(body["action"].as_str(), Some("typing")); + assert_eq!( + body["message_thread_id"].as_str(), + Some("42"), + "forum topic must carry message_thread_id in typing body" + ); +} + +// ── Update tracking edge cases ────────────────────────────────── + +#[test] +fn track_update_id_accepts_monotonically_increasing_sequence() { + let ch = TelegramChannel::new("t".into(), vec!["*".into()], false); + for id in 1..=20i64 { + assert!( + ch.track_update_id(id), + "monotonically increasing id {id} should be accepted" + ); + } +} + +#[test] +fn track_update_id_large_volume_beyond_cache_does_not_panic() { + // TELEGRAM_RECENT_UPDATE_CACHE_SIZE is 4096; push well beyond to exercise eviction. + let ch = TelegramChannel::new("t".into(), vec!["*".into()], false); + for id in 1..=5000i64 { + ch.track_update_id(id); + } + // After eviction, the next fresh id is still accepted. + assert!( + ch.track_update_id(5001), + "first occurrence of a new id must always be accepted" + ); +} + +#[test] +fn silent_streaming_is_configurable() { + let silent = TelegramChannel::new("fake-token".into(), vec!["*".into()], false).with_streaming( + StreamMode::Partial, + 1000, + true, + ); + assert!(silent.silent_streaming); + + let noisy = TelegramChannel::new("fake-token".into(), vec!["*".into()], false).with_streaming( + StreamMode::Partial, + 1000, + false, + ); + assert!(!noisy.silent_streaming); +} + +// ── Reply-target parsing unit tests ──────────────────────────── + +#[test] +fn parse_reply_target_splits_chat_and_thread_on_colon() { + let (chat_id, thread_id) = TelegramChannel::parse_reply_target("12345:789"); + assert_eq!(chat_id, "12345"); + assert_eq!(thread_id.as_deref(), Some("789")); +} + +#[test] +fn parse_reply_target_no_colon_returns_plain_chat_id() { + let (chat_id, thread_id) = TelegramChannel::parse_reply_target("-100200300"); + assert_eq!(chat_id, "-100200300"); + assert!(thread_id.is_none()); +} + +#[test] +fn parse_update_message_without_reply_to_still_sets_thread_ts_to_own_message_id() { + // Every inbound message sets thread_ts = its own message_id so the outbound + // reply attaches visibly in Telegram. This applies even with no reply_to_message. + let ch = TelegramChannel::new("token".into(), vec!["*".into()], false); + let update = serde_json::json!({ + "update_id": 60, + "message": { + "message_id": 55, + "text": "standalone message", + "from": { "id": 1, "username": "alice" }, + "chat": { "id": 100 } + } + }); + let msg = ch.parse_update_message(&update).expect("should parse"); + assert_eq!( + msg.thread_ts.as_deref(), + Some("55"), + "thread_ts must equal inbound message_id for reply targeting" + ); + assert_eq!(msg.reply_target, "100"); +} + +#[test] +fn parse_update_message_forum_topic_encodes_thread_in_reply_target_and_thread_ts() { + // Forum-topic messages carry message_thread_id (topic) AND may have reply_to_message. + // reply_target must be chat_id:thread_id; thread_ts must be the inbound message_id. + let ch = TelegramChannel::new("token".into(), vec!["*".into()], false); + let update = serde_json::json!({ + "update_id": 61, + "message": { + "message_id": 100, + "text": "in a forum topic with a quoted reply", + "from": { "id": 1, "username": "alice" }, + "chat": { "id": -200 }, + "message_thread_id": 42, + "reply_to_message": { "message_id": 90 } + } + }); + let msg = ch.parse_update_message(&update).expect("should parse"); + assert_eq!( + msg.reply_target, "-200:42", + "reply_target must encode both chat_id and topic thread_id" + ); + assert_eq!( + msg.thread_ts.as_deref(), + Some("100"), + "thread_ts is the inbound message_id (not the quoted parent)" + ); +} + +// NOTE: `test_thinking_placeholder_logic` was intentionally dropped during the +// port into TinyChannels. It drove draft-update dispatch off OpenHuman's +// `crate::openhuman::agent::progress::AgentProgress`, a host-side streaming type +// with no equivalent in TinyChannels — it exercised host glue, not the Telegram +// transport. Re-home it in OpenHuman if that dispatch path needs coverage. + +// ── Issue #1948: Duplicate approval prompts regression tests ────────────────── +// +// These tests cover the race-guard and de-bounce logic added in fix/1948. +// They FAIL to compile (method not found) before the fix is applied. + +/// Test A (race-guard): channel constructed with allowed_users=["alice"] has pairing=None. +/// When runtime allowlist is cleared to empty (simulating restart-race), `is_race_condition_instance` +/// must return `true` so the approval prompt is suppressed. +#[test] +fn is_race_condition_instance_true_when_allowlist_was_nonempty_but_runtime_is_empty() { + let ch = TelegramChannel::new("t".into(), vec!["alice".into()], false); + // Channel constructed with non-empty list → pairing is None + assert!( + ch.pairing.is_none(), + "pre-condition: non-empty allowlist must set pairing=None" + ); + + // Simulate the race: runtime allowlist cleared by an in-flight config reload + { + let mut users = ch.allowed_users.write().unwrap(); + users.clear(); + } + + assert!( + ch.is_race_condition_instance(), + "[telegram][approval] race guard must fire: runtime empty AND pairing=None" + ); +} + +/// Test B (legit pairing preserved): channel constructed with empty allowlist → pairing=Some. +/// `is_race_condition_instance` must return `false` so the first-run pairing flow is unaffected. +#[test] +fn is_race_condition_instance_false_for_legitimate_empty_allowlist() { + let ch = TelegramChannel::new("t".into(), vec![], false); + // Channel constructed with empty list → pairing is Some (first-run pairing) + assert!( + ch.pairing.is_some(), + "pre-condition: empty allowlist must set pairing=Some" + ); + + // Runtime list is also empty (genuine first-run state) + assert!( + !ch.is_race_condition_instance(), + "[telegram][approval] race guard must NOT fire for genuine first-run pairing" + ); +} + +/// Test C (genuine reject preserved): channel with allowed_users=["alice"], "bob" is not allowed. +/// `is_race_condition_instance` must return `false` when runtime list is non-empty (normal case). +#[test] +fn is_race_condition_instance_false_when_runtime_allowlist_populated() { + let ch = TelegramChannel::new("t".into(), vec!["alice".into()], false); + assert!( + ch.pairing.is_none(), + "pre-condition: non-empty allowlist must set pairing=None" + ); + + // Runtime list is populated (normal operational state — NOT a race) + let users = ch.allowed_users.read().unwrap(); + assert!( + !users.is_empty(), + "pre-condition: runtime list should still contain alice" + ); + drop(users); + + assert!( + !ch.is_race_condition_instance(), + "[telegram][approval] race guard must NOT fire when runtime allowlist is populated" + ); +} + +/// Test D (de-bounce — first call): verify that the first approval prompt is NOT suppressed. +#[test] +fn approval_debounce_first_call_not_suppressed() { + let ch = TelegramChannel::new("t".into(), vec![], false); + let suppressed = ch.check_and_update_approval_debounce("12345", "alice"); + assert!( + !suppressed, + "[telegram][approval] first approval prompt must not be suppressed" + ); +} + +/// Test D (de-bounce — rapid second call): a second call within the window must be suppressed. +#[test] +fn approval_debounce_rapid_second_call_suppressed() { + let ch = TelegramChannel::new("t".into(), vec![], false); + + // First call registers the timestamp + let first = ch.check_and_update_approval_debounce("12345", "alice"); + assert!(!first, "first call must not be suppressed"); + + // Immediate second call — still within the 60s window + let second = ch.check_and_update_approval_debounce("12345", "alice"); + assert!( + second, + "[telegram][approval] rapid second approval prompt to same chat+sender must be suppressed" + ); +} + +/// Test D (de-bounce — different sender): a different sender in the same chat is NOT suppressed. +#[test] +fn approval_debounce_different_sender_not_suppressed() { + let ch = TelegramChannel::new("t".into(), vec![], false); + + let _ = ch.check_and_update_approval_debounce("12345", "alice"); + + // "bob" sending to the same chat is a different key — must not be suppressed + let suppressed = ch.check_and_update_approval_debounce("12345", "bob"); + assert!( + !suppressed, + "[telegram][approval] different sender must not be suppressed by alice's de-bounce" + ); +} + +/// Test D (de-bounce — different chat): the same sender in a different chat is NOT suppressed. +#[test] +fn approval_debounce_different_chat_not_suppressed() { + let ch = TelegramChannel::new("t".into(), vec![], false); + + let _ = ch.check_and_update_approval_debounce("chat_a", "mallory"); + + // Same sender, different chat — different key — must not be suppressed + let suppressed = ch.check_and_update_approval_debounce("chat_b", "mallory"); + assert!( + !suppressed, + "[telegram][approval] different chat must not be suppressed by chat_a's de-bounce" + ); +} + +/// Test D (de-bounce — window expiry): after advancing the clock past the de-bounce window, +/// the same chat+sender is allowed again. +/// +/// We can't advance a real clock cheaply in a unit test, so we instead verify that the +/// de-bounce bucket is re-inserted with a fresh timestamp on every non-suppressed call, +/// by checking that the map entry is updated when the first call fires. +#[test] +fn approval_debounce_map_entry_inserted_on_first_call() { + let ch = TelegramChannel::new("t".into(), vec![], false); + + { + let prompts = ch.recent_approval_prompts.lock(); + assert!( + prompts.is_empty(), + "map must be empty before any approval prompt" + ); + } + + let suppressed = ch.check_and_update_approval_debounce("99", "mallory"); + assert!(!suppressed); + + { + let prompts = ch.recent_approval_prompts.lock(); + let key = TelegramChannel::approval_debounce_key("99", "mallory"); + assert!( + prompts.contains_key(&key), + "[telegram][approval] first call must register entry in recent_approval_prompts map" + ); + } +} + +/// Test D (de-bounce — multiple calls, 4 rapid): four rapid calls from the same sender must result +/// in exactly one entry in the map and only the first call being non-suppressed. +#[test] +fn approval_debounce_four_rapid_calls_suppressed_after_first() { + let ch = TelegramChannel::new("t".into(), vec![], false); + + let mut not_suppressed_count = 0usize; + for _ in 0..4 { + if !ch.check_and_update_approval_debounce("777", "spammer") { + not_suppressed_count += 1; + } + } + + assert_eq!( + not_suppressed_count, 1, + "[telegram][approval] exactly 1 of 4 rapid calls must not be suppressed (de-bounce)" + ); +} + +/// Review note on #1948 (@graycyrus): the de-bounce map must not grow without +/// bound. Entries older than the de-bounce window are dead weight — they can +/// never suppress again — so a non-suppressed call evicts them. Pre-seed one +/// stale entry and one fresh entry, trigger a new non-suppressed call, and +/// assert the stale entry is gone while the fresh and new ones remain. +#[test] +fn approval_debounce_evicts_entries_past_window() { + let ch = TelegramChannel::new("t".into(), vec![], false); + let stale = std::time::Instant::now() + .checked_sub(Duration::from_secs(3600)) + .expect("monotonic clock far enough from boot for a 1h offset"); + + { + let mut prompts = ch.recent_approval_prompts.lock(); + prompts.insert("stale_chat:stale_sender".to_string(), stale); + prompts.insert( + "fresh_chat:fresh_sender".to_string(), + std::time::Instant::now(), + ); + } + + // Non-suppressed call for a new key triggers the eviction sweep + insert. + let suppressed = ch.check_and_update_approval_debounce("new_chat", "new_sender"); + assert!( + !suppressed, + "first call for a new key must not be suppressed" + ); + + let prompts = ch.recent_approval_prompts.lock(); + assert!( + !prompts.contains_key("stale_chat:stale_sender"), + "[telegram][approval] stale entry past the de-bounce window must be evicted (no unbounded growth)" + ); + assert!( + prompts.contains_key("fresh_chat:fresh_sender"), + "[telegram][approval] fresh entry within the window must be retained" + ); + assert!( + prompts.contains_key(&TelegramChannel::approval_debounce_key( + "new_chat", + "new_sender" + )), + "[telegram][approval] the new entry must be inserted" + ); +} + +#[tokio::test] +async fn start_onboarding_is_private_only_and_consumes_the_code() { + // Aim outbound sends at a dead local port so the approve/hint `self.send()` + // fast-fails (connection refused) instead of reaching api.telegram.org — the + // onboarding *decision* (runtime allowlist + one-time code) is asserted + // regardless of the send outcome. + let mut ch = TelegramChannel::new("fake-token".into(), vec![], false); + ch.set_api_base_for_tests("http://127.0.0.1:1"); + assert!(ch.allowlist_is_empty()); + assert!( + ch.pairing_code_active(), + "fresh pairing-mode channel arms a code" + ); + + // A PRIVATE `/start` onboards the first sender AND consumes the code, so it + // can't later be replayed via `/bind`. + let private_start = serde_json::json!({ + "message": { + "chat": { "id": 111, "type": "private" }, + "from": { "id": 222, "username": "operator" }, + "text": "/start" + } + }); + ch.handle_unauthorized_message(&private_start).await; + assert!( + !ch.allowlist_is_empty(), + "private /start onboards the operator" + ); + assert!( + !ch.pairing_code_active(), + "the one-time code is consumed on /start onboarding" + ); + + // A GROUP `/start` must NOT onboard — otherwise any member could claim + // operator ownership. It falls through to the normal approval prompt. + let mut group_ch = TelegramChannel::new("fake-token".into(), vec![], false); + group_ch.set_api_base_for_tests("http://127.0.0.1:1"); + let group_start = serde_json::json!({ + "message": { + "chat": { "id": -100, "type": "supergroup" }, + "from": { "id": 333, "username": "member" }, + "text": "/start" + } + }); + group_ch.handle_unauthorized_message(&group_start).await; + assert!( + group_ch.allowlist_is_empty(), + "a group /start must not onboard anyone" + ); + assert!( + group_ch.pairing_code_active(), + "a group /start leaves the one-time code intact" + ); +} diff --git a/src/providers/telegram/channel_types.rs b/src/providers/telegram/channel_types.rs new file mode 100644 index 0000000..2d5e691 --- /dev/null +++ b/src/providers/telegram/channel_types.rs @@ -0,0 +1,87 @@ +//! Telegram channel — private types and the main struct definition. + +use crate::config::StreamMode; +use crate::security::PairingGuard; +use parking_lot::Mutex; +use std::collections::{HashSet, VecDeque}; +use std::sync::{Arc, RwLock}; +use std::time::Instant; + +pub(crate) const TELEGRAM_RECENT_UPDATE_CACHE_SIZE: usize = 4096; + +/// Telegram Bot API caps downloaded files at 20MB for `getFile`. +/// Keep our inbound voice path inside the same bound before base64 encoding +/// and dispatching to STT. +pub(crate) const TELEGRAM_MAX_VOICE_FILE_BYTES: u64 = 20 * 1024 * 1024; + +/// De-bounce window for approval prompts: suppress duplicate prompts sent to the +/// same chat+sender within this duration (prevents restart-race and rapid-fire spam). +pub(crate) const APPROVAL_PROMPT_DEBOUNCE_SECS: u64 = 60; + +pub(crate) struct TelegramTypingTask { + pub(crate) recipient: String, + pub(crate) handle: tokio::task::JoinHandle<()>, +} + +#[derive(Default)] +pub(crate) struct TelegramUpdateWindow { + pub(crate) max_seen_update_id: i64, + pub(crate) recent_order: VecDeque, + pub(crate) recent_lookup: HashSet, +} + +#[derive(Debug, Clone)] +pub(crate) struct TelegramReactionEvent { + pub(crate) sender: String, + pub(crate) reply_target: String, + pub(crate) target_message_id: String, + pub(crate) emoji: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TelegramVoiceAttachment { + pub(crate) file_id: String, + pub(crate) file_unique_id: Option, + pub(crate) file_size: Option, + pub(crate) mime_type: Option, +} + +/// Telegram channel — long-polls the Bot API for updates +pub struct TelegramChannel { + pub(crate) bot_token: String, + /// Default chat for recipient-less proactive sends. `None` ⇒ proactive + /// routing skips Telegram (see `proactive_target`). Set from + /// `TelegramConfig::chat_id` via [`TelegramChannel::with_chat_id`]. + pub(crate) chat_id: Option, + /// Base URL for the Telegram Bot API. Defaults to `https://api.telegram.org`. + /// Override via `OPENHUMAN_TELEGRAM_BOT_API_BASE` for E2E testing against a + /// mock server. The legacy `OPENHUMAN_TELEGRAM_API_BASE` alias is still accepted. + pub(crate) api_base: String, + pub(crate) allowed_users: Arc>>, + pub(crate) pairing: Option, + pub(crate) client: reqwest::Client, + /// Injected speech-to-text capability for inbound voice notes. `None` ⇒ + /// voice transcription is skipped (a warn is logged). Set via + /// [`TelegramChannel::with_transcriber`]. + pub(crate) transcriber: Option>, + /// Injected persisted-allowlist store. On first-run bind/pairing the paired + /// identity is promoted through this so it survives restarts. `None` ⇒ + /// runtime-only allowlisting. Set via [`TelegramChannel::with_allowlist`]. + pub(crate) allowlist: Option>, + /// Injected domain event sink for reaction fan-out. `None` ⇒ reactions are + /// still applied but no event is published. Set via + /// [`TelegramChannel::with_events`]. + pub(crate) events: Option>, + pub(crate) typing_handle: Mutex>, + pub(crate) stream_mode: StreamMode, + pub(crate) draft_update_interval_ms: u64, + pub(crate) silent_streaming: bool, + pub(crate) last_draft_edit: Mutex>, + pub(crate) mention_only: bool, + pub(crate) bot_username: Mutex>, + pub(crate) recent_updates: Mutex, + /// Tracks the last time an approval prompt was sent to a given "chat_id:sender" key. + /// Prevents duplicate prompts during restart-overlap races and rapid re-sends. + /// Mirrors the `last_draft_edit` pattern. + pub(crate) recent_approval_prompts: Mutex>, +} diff --git a/src/providers/telegram/mod.rs b/src/providers/telegram/mod.rs new file mode 100644 index 0000000..233dee6 --- /dev/null +++ b/src/providers/telegram/mod.rs @@ -0,0 +1,28 @@ +//! Telegram channel — long-polls the Bot API for updates. +//! +//! This is the **transport** half of the Telegram provider, ported into +//! TinyChannels. Host glue (remote control, event-bus subscribers, approval +//! surface) stays in OpenHuman and re-exports [`TelegramChannel`] from here. + +mod attachments; +mod channel; +mod channel_core; +mod channel_ops; +mod channel_recv; +mod channel_send; +mod channel_types; +pub mod session_store; +mod text; + +pub use channel_types::TelegramChannel; + +#[cfg(any(test, debug_assertions))] +pub mod test_support { + //! Debug-build seams for raw integration coverage of Telegram send helpers. + + use super::TelegramChannel; + + pub fn parse_reaction_marker_for_test(content: &str) -> (String, Option) { + TelegramChannel::parse_reaction_marker(content) + } +} diff --git a/src/providers/telegram/session_store.rs b/src/providers/telegram/session_store.rs new file mode 100644 index 0000000..02c1861 --- /dev/null +++ b/src/providers/telegram/session_store.rs @@ -0,0 +1,236 @@ +//! Workspace-backed Telegram chat → thread bindings for remote control. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +const STORE_FILE: &str = "state/telegram_remote_sessions.json"; +const LOG_PREFIX: &str = "[telegram-remote]"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TelegramChatBinding { + pub thread_id: String, + pub sender_key: String, + pub updated_at: String, + /// Human-readable title captured at `/new` time so `/status` can display it + /// without listing all threads (O(1) instead of O(n) disk reads). + #[serde(default)] + pub title: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct TelegramSessionStoreFile { + bindings: HashMap, + #[serde(default)] + busy_reply_targets: HashMap, +} + +pub struct TelegramSessionStore { + file: TelegramSessionStoreFile, + path: PathBuf, +} + +impl TelegramSessionStore { + pub fn load(workspace_dir: &Path) -> anyhow::Result { + let path = workspace_dir.join(STORE_FILE); + let file = if path.exists() { + let raw = std::fs::read_to_string(&path)?; + serde_json::from_str(&raw).unwrap_or_else(|error| { + tracing::warn!( + "{LOG_PREFIX} corrupt session store at {}: {error}; resetting", + path.display() + ); + TelegramSessionStoreFile::default() + }) + } else { + TelegramSessionStoreFile::default() + }; + tracing::debug!( + "{LOG_PREFIX} loaded session store bindings={} busy={}", + file.bindings.len(), + file.busy_reply_targets.len() + ); + Ok(Self { file, path }) + } + + pub fn save(&self) -> anyhow::Result<()> { + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + let raw = serde_json::to_string_pretty(&self.file)?; + std::fs::write(&self.path, raw)?; + Ok(()) + } + + pub fn binding(&self, reply_target: &str) -> Option<&TelegramChatBinding> { + self.file.bindings.get(reply_target) + } + + pub fn set_binding( + &mut self, + reply_target: &str, + thread_id: String, + sender_key: String, + title: Option, + ) { + let updated_at = chrono::Utc::now().to_rfc3339(); + self.file.bindings.insert( + reply_target.to_string(), + TelegramChatBinding { + thread_id, + sender_key, + updated_at, + title, + }, + ); + } + + pub fn set_busy(&mut self, reply_target: &str, busy: bool) { + if busy { + self.file + .busy_reply_targets + .insert(reply_target.to_string(), true); + } else { + self.file.busy_reply_targets.remove(reply_target); + } + } + + pub fn is_busy(&self, reply_target: &str) -> bool { + self.file + .busy_reply_targets + .get(reply_target) + .copied() + .unwrap_or(false) + } +} + +static STORE: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + +/// Read-write accessor: runs `f` against the cached store, then flushes to disk. +/// Use for operations that mutate state (e.g. `set_binding`, `set_busy`). +pub fn with_store(workspace_dir: &Path, f: F) -> anyhow::Result +where + F: FnOnce(&mut TelegramSessionStore) -> anyhow::Result, +{ + let lock = STORE.get_or_init(|| std::sync::Mutex::new(None)); + let mut guard = lock.lock().expect("telegram session store mutex poisoned"); + let expected_path = workspace_dir.join(STORE_FILE); + let needs_load = guard + .as_ref() + .map(|store| store.path != expected_path) + .unwrap_or(true); + if needs_load { + *guard = Some(TelegramSessionStore::load(workspace_dir)?); + } + let store = guard.as_mut().expect("store initialized"); + let result = f(store)?; + store.save()?; + Ok(result) +} + +/// Read-only accessor: runs `f` against the cached store but does **not** flush +/// to disk. Use for operations that only read state (e.g. `binding`, `is_busy`) +/// to avoid unnecessary serialization and disk I/O on every query. +pub fn with_store_read(workspace_dir: &Path, f: F) -> anyhow::Result +where + F: FnOnce(&TelegramSessionStore) -> anyhow::Result, +{ + let lock = STORE.get_or_init(|| std::sync::Mutex::new(None)); + let mut guard = lock.lock().expect("telegram session store mutex poisoned"); + let expected_path = workspace_dir.join(STORE_FILE); + let needs_load = guard + .as_ref() + .map(|store| store.path != expected_path) + .unwrap_or(true); + if needs_load { + *guard = Some(TelegramSessionStore::load(workspace_dir)?); + } + let store = guard.as_ref().expect("store initialized"); + f(store) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn round_trip_binding_and_busy_flag() { + let dir = tempdir().expect("tempdir"); + let mut store = TelegramSessionStore::load(dir.path()).expect("load"); + store.set_binding( + "12345", + "thread-abc".into(), + "telegram_alice_12345".into(), + Some("My Session".into()), + ); + store.set_busy("12345", true); + store.save().expect("save"); + + let reloaded = TelegramSessionStore::load(dir.path()).expect("reload"); + let binding = reloaded.binding("12345").expect("binding"); + assert_eq!(binding.thread_id, "thread-abc"); + assert_eq!(binding.title.as_deref(), Some("My Session")); + assert!(reloaded.is_busy("12345")); + } + + #[test] + fn corrupt_store_resets_and_clearing_busy_removes_flag() { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join(STORE_FILE); + std::fs::create_dir_all(path.parent().expect("state dir")).expect("state dir"); + std::fs::write(&path, "{ not valid json").expect("write corrupt store"); + + let mut store = TelegramSessionStore::load(dir.path()).expect("load corrupt store"); + assert!(store.binding("12345").is_none()); + + store.set_busy("12345", true); + assert!(store.is_busy("12345")); + store.set_busy("12345", false); + assert!(!store.is_busy("12345")); + store.save().expect("save reset store"); + + let raw = std::fs::read_to_string(path).expect("read saved store"); + assert!(raw.contains("\"bindings\"")); + assert!(!raw.contains("12345")); + } + + /// Tests `with_store` workspace-change detection by using `TelegramSessionStore` + /// directly for cross-workspace assertions — avoids races with the process-global + /// `STORE` singleton when tests run in parallel. + #[test] + fn store_isolates_bindings_across_workspaces() { + let first = tempdir().expect("first tempdir"); + let second = tempdir().expect("second tempdir"); + + // Write binding into first workspace directly (no global singleton). + let mut store_a = TelegramSessionStore::load(first.path()).expect("load first"); + store_a.set_binding("chat-a", "thread-a".into(), "telegram_a".into(), None); + store_a.save().expect("save first"); + + // Write binding into second workspace directly. + let mut store_b = TelegramSessionStore::load(second.path()).expect("load second"); + assert!( + store_b.binding("chat-a").is_none(), + "second workspace must not see first workspace's binding" + ); + store_b.set_binding("chat-b", "thread-b".into(), "telegram_b".into(), None); + store_b.save().expect("save second"); + + let first_store = TelegramSessionStore::load(first.path()).expect("reload first"); + let second_store = TelegramSessionStore::load(second.path()).expect("reload second"); + assert_eq!( + first_store + .binding("chat-a") + .map(|binding| binding.thread_id.as_str()), + Some("thread-a") + ); + assert_eq!( + second_store + .binding("chat-b") + .map(|binding| binding.thread_id.as_str()), + Some("thread-b") + ); + } +} diff --git a/src/providers/telegram/text.rs b/src/providers/telegram/text.rs new file mode 100644 index 0000000..585afb0 --- /dev/null +++ b/src/providers/telegram/text.rs @@ -0,0 +1,131 @@ +//! Text chunking and tool-call tag stripping for Telegram. + +use crate::channel::LengthUnit; +use crate::text::{ChunkMode, TextChunkOptions, chunk_text_with_options}; + +/// Telegram's maximum message length for text messages +pub(crate) const TELEGRAM_MAX_MESSAGE_LENGTH: usize = 4096; +pub(crate) const TELEGRAM_BIND_COMMAND: &str = "/bind"; +pub(crate) const TELEGRAM_START_COMMAND: &str = "/start"; + +pub(crate) fn split_message_for_telegram(message: &str) -> Vec { + if message.is_empty() { + return vec![String::new()]; + } + chunk_text_with_options( + message, + TextChunkOptions { + limit: TELEGRAM_MAX_MESSAGE_LENGTH, + length_unit: LengthUnit::Utf16Units, + mode: ChunkMode::Length, + markdown: true, + indicators: false, + }, + ) +} + +pub(crate) fn strip_tool_call_tags(message: &str) -> String { + const TOOL_CALL_OPEN_TAGS: [&str; 5] = [ + "", + "", + "", + "", + "", + ]; + + fn find_first_tag<'a>(haystack: &str, tags: &'a [&'a str]) -> Option<(usize, &'a str)> { + tags.iter() + .filter_map(|tag| haystack.find(tag).map(|idx| (idx, *tag))) + .min_by_key(|(idx, _)| *idx) + } + + fn matching_close_tag(open_tag: &str) -> Option<&'static str> { + match open_tag { + "" => Some(""), + "" => Some(""), + "" => Some(""), + "" => Some(""), + "" => Some(""), + _ => None, + } + } + + fn extract_first_json_end(input: &str) -> Option { + let trimmed = input.trim_start(); + let trim_offset = input.len().saturating_sub(trimmed.len()); + + for (byte_idx, ch) in trimmed.char_indices() { + if ch != '{' && ch != '[' { + continue; + } + + let slice = &trimmed[byte_idx..]; + let mut stream = + serde_json::Deserializer::from_str(slice).into_iter::(); + if let Some(Ok(_value)) = stream.next() { + let consumed = stream.byte_offset(); + if consumed > 0 { + return Some(trim_offset + byte_idx + consumed); + } + } + } + + None + } + + fn strip_leading_close_tags(mut input: &str) -> &str { + loop { + let trimmed = input.trim_start(); + if !trimmed.starts_with("') else { + return ""; + }; + input = &trimmed[close_end + 1..]; + } + } + + let mut kept_segments = Vec::new(); + let mut remaining = message; + + while let Some((start, open_tag)) = find_first_tag(remaining, &TOOL_CALL_OPEN_TAGS) { + let before = &remaining[..start]; + if !before.is_empty() { + kept_segments.push(before.to_string()); + } + + let Some(close_tag) = matching_close_tag(open_tag) else { + break; + }; + let after_open = &remaining[start + open_tag.len()..]; + + if let Some(close_idx) = after_open.find(close_tag) { + remaining = &after_open[close_idx + close_tag.len()..]; + continue; + } + + if let Some(consumed_end) = extract_first_json_end(after_open) { + remaining = strip_leading_close_tags(&after_open[consumed_end..]); + continue; + } + + kept_segments.push(remaining[start..].to_string()); + remaining = ""; + break; + } + + if !remaining.is_empty() { + kept_segments.push(remaining.to_string()); + } + + let mut result = kept_segments.concat(); + + // Clean up any resulting blank lines (but preserve paragraphs) + while result.contains("\n\n\n") { + result = result.replace("\n\n\n", "\n\n"); + } + + result.trim().to_string() +} diff --git a/src/providers/whatsapp_web.rs b/src/providers/whatsapp_web.rs new file mode 100644 index 0000000..cd74bad --- /dev/null +++ b/src/providers/whatsapp_web.rs @@ -0,0 +1,645 @@ +//! WhatsApp Web channel backed by upstream [`whatsapp-rust`] 0.5. +//! +//! # Why the upgrade +//! +//! The previous implementation used `wa-rs` 0.2 (a fork that pinned to stable +//! Rust). That fork silently dropped `Event::Message` for LID-addressed +//! contacts and group sender-key (`skmsg`) messages: the protocol layer +//! decrypted the payload but never dispatched it to user code, breaking +//! agent dispatch for the bulk of modern WhatsApp traffic (LID is the +//! current default). Upstream `whatsapp-rust` 0.5 fixed this in PRs #170 +//! (SKDM tracking) + #181 (LID/PN mapping) + sender-key dispatch. +//! +//! # Feature Flag +//! +//! ```sh +//! cargo build --features whatsapp-web +//! ``` +//! +//! # Configuration +//! +//! ```toml +//! [channels.whatsapp] +//! session_path = "~/.openhuman/whatsapp-session.db" # Reserved for durable Web mode +//! pair_phone = "15551234567" # Optional: pair-code linking +//! allowed_numbers = ["+1234567890", "*"] # Same shape as Cloud API +//! ``` +//! +//! # Runtime negotiation +//! +//! Selected automatically by the channels runtime startup when `session_path` +//! is set. The Cloud API channel ([`super::whatsapp`]) is used when +//! `phone_number_id` is set instead. +//! +//! # Migration note +//! +//! The upstream 0.5 `sqlite-storage` feature currently uses Diesel, whose +//! native sqlite binding conflicts with the TinyAgents 1.3 / rusqlite 0.40 +//! baseline. Until OpenHuman owns a rusqlite-backed durable store for the +//! WhatsApp backend traits, this channel uses wacore's in-memory backend and +//! requires re-linking after restart. +//! +//! [`whatsapp-rust`]: https://docs.rs/whatsapp-rust/0.5 + +use crate::traits::{Channel, ChannelMessage, SendMessage}; +use anyhow::Result; +use async_trait::async_trait; +use std::sync::Arc; + +// Imports used only by the real (feature-on) implementation; gated so the +// feature-off stub build doesn't flag them as unused. +#[cfg(feature = "whatsapp-web")] +use anyhow::anyhow; +#[cfg(feature = "whatsapp-web")] +use parking_lot::Mutex; +#[cfg(feature = "whatsapp-web")] +use std::collections::HashSet; +#[cfg(feature = "whatsapp-web")] +use std::sync::atomic::{AtomicBool, Ordering}; + +/// WhatsApp Web channel. +/// +/// Wraps a `whatsapp-rust` Bot with our `Channel` trait. The bot owns an +/// `Arc` for outbound operations (`send`, typing) and a `BotHandle` +/// for shutdown. Inbound messages are pushed onto an [`mpsc::Sender`] so +/// the existing channel inbound subscriber pipeline can process them. +#[cfg(feature = "whatsapp-web")] +pub struct WhatsAppWebChannel { + /// Path to the SQLite session database. + session_path: String, + /// Optional phone number for pair-code linking (E.164 digits, no leading `+`). + pair_phone: Option, + /// Optional pre-allocated pair code paired with `pair_phone`. + pair_code: Option, + /// E.164 numbers (with leading `+`) allowed to interact, or `["*"]` for any. + /// Empty also means "allow all" — same convention as the Cloud API channel. + allowed_numbers: Vec, + /// Bot run handle, retained for graceful shutdown. + bot_handle: Arc>>, + /// Live client used for outbound calls; populated after `Bot::build` returns. + client: Arc>>>, + /// Liveness signal driven by upstream `Event::Connected` / `LoggedOut` / + /// `StreamError`. Used by `health_check` so a dropped session no longer + /// reports healthy until process shutdown. + connected: Arc, + /// Group JIDs (`...@g.us`) we've already accepted an allowed inbound + /// from. Acts as outbound provenance: replies into a group are only + /// permitted after a participant on the per-number allowlist messaged + /// in. Without this, any caller able to pass a `recipient` could post + /// into arbitrary joined groups via the @g.us suffix. + allowed_groups: Arc>>, + /// Sink for inbound `ChannelMessage`s. Populated when [`Channel::listen`] + /// is called and shared with the event-handler closure. + tx: Arc>>>, + /// Host lifecycle capability used to register graceful-shutdown teardown. + /// When absent, teardown is not wired into the host and `listen` still + /// parks on the internal notify gate. + lifecycle: Option>, +} + +#[cfg(feature = "whatsapp-web")] +impl WhatsAppWebChannel { + /// Construct a channel. The bot does not connect until [`Channel::listen`] + /// is invoked. + pub fn new( + session_path: String, + pair_phone: Option, + pair_code: Option, + allowed_numbers: Vec, + ) -> Self { + Self { + session_path, + pair_phone, + pair_code, + allowed_numbers, + bot_handle: Arc::new(Mutex::new(None)), + client: Arc::new(Mutex::new(None)), + connected: Arc::new(AtomicBool::new(false)), + allowed_groups: Arc::new(Mutex::new(HashSet::new())), + tx: Arc::new(Mutex::new(None)), + lifecycle: None, + } + } + + /// Wire in the host [`crate::host::LifecycleRegistry`] so `listen` can + /// register its graceful-shutdown teardown with the host runtime. + pub fn with_lifecycle( + mut self, + lifecycle: std::sync::Arc, + ) -> Self { + self.lifecycle = Some(lifecycle); + self + } + + /// Allowlist check. Empty list ⇒ allow-all (matches Cloud API behaviour). + fn is_number_allowed(&self, phone: &str) -> bool { + self.allowed_numbers.is_empty() + || self.allowed_numbers.iter().any(|n| n == "*" || n == phone) + } + + /// Recognise WhatsApp group JIDs (`...@g.us`). Group recipients bypass + /// the per-number outbound allowlist because group membership is + /// governed by WhatsApp itself; the inbound side already gated on the + /// participant's allowlist status before we ever decided to reply. + fn is_group_jid(recipient: &str) -> bool { + recipient.trim().ends_with("@g.us") + } + + /// Outbound gate combining group-provenance with the per-number allowlist. + /// Group JIDs are only permitted when an allowed inbound has already + /// been received from that exact group — populated in the inbound + /// handler when an allow-listed participant posts. This narrows the + /// previous "all `@g.us` is fine" path so an attacker that can supply + /// a `recipient` cannot post into arbitrary groups the bot has joined. + fn should_allow_outbound(&self, recipient: &str) -> bool { + if Self::is_group_jid(recipient) { + return self.allowed_groups.lock().contains(recipient.trim()); + } + let normalized = self.normalize_phone(recipient); + self.is_number_allowed(&normalized) + } + + /// Mask a recipient identifier for log emission. Handles bare phone + /// numbers, `@s.whatsapp.net`/`@lid` DM JIDs, and `@g.us` + /// group JIDs uniformly so warning paths never carry a full ID. + fn redact_recipient(recipient: &str) -> String { + let trimmed = recipient.trim(); + if let Some((user, server)) = trimmed.split_once('@') { + format!("{}@{}", Self::redact_phone(user), server) + } else { + Self::redact_phone(trimmed) + } + } + + /// Pick the address downstream replies should be sent back to. + /// + /// Group chats are addressed by the group JID (`...@g.us`); a reply that + /// targeted the participant's phone instead would leak the conversation + /// into a private DM. + fn compute_reply_target(chat_jid: &str, sender_normalized: &str) -> String { + if chat_jid.ends_with("@g.us") { + chat_jid.to_string() + } else { + sender_normalized.to_string() + } + } + + /// Mask the middle digits of an E.164 number so logs only carry a coarse + /// fingerprint instead of the full identifier. + fn redact_phone(phone: &str) -> String { + let prefix = if phone.starts_with('+') { "+" } else { "" }; + if phone.len() <= prefix.len() + 4 { + return format!("{prefix}****"); + } + let tail = &phone[phone.len() - 4..]; + format!("{prefix}***{tail}") + } + + /// Pull the displayable text out of an inbound WhatsApp Message proto. + /// Falls back from `conversation` to `extended_text_message.text`, then + /// to an empty string for non-text payloads. + fn extract_message_text(conversation: Option<&str>, extended_text: Option<&str>) -> String { + conversation + .or(extended_text) + .map(|s| s.to_string()) + .unwrap_or_default() + } + + /// Render an arbitrary recipient string as E.164 with a leading `+`, + /// stripping any `@server` JID suffix the caller passed in. + fn normalize_phone(&self, phone: &str) -> String { + let trimmed = phone.trim(); + let user_part = trimmed + .split_once('@') + .map(|(user, _)| user) + .unwrap_or(trimmed); + let normalized_user = user_part.trim_start_matches('+'); + format!("+{normalized_user}") + } + + /// Convert a recipient (full JID like `12345@s.whatsapp.net` or an E.164 + /// number like `+1234567890`) into a `whatsapp-rust` JID. + fn recipient_to_jid(&self, recipient: &str) -> Result { + let trimmed = recipient.trim(); + if trimmed.is_empty() { + anyhow::bail!("Recipient cannot be empty"); + } + + if trimmed.contains('@') { + return trimmed + .parse::() + .map_err(|e| anyhow!("Invalid WhatsApp JID `{trimmed}`: {e}")); + } + + let digits: String = trimmed.chars().filter(|c| c.is_ascii_digit()).collect(); + if digits.is_empty() { + anyhow::bail!("Recipient `{trimmed}` does not contain a valid phone number"); + } + + Ok(whatsapp_rust::Jid::pn(digits)) + } +} + +#[cfg(feature = "whatsapp-web")] +#[async_trait] +impl Channel for WhatsAppWebChannel { + fn name(&self) -> &str { + "whatsapp" + } + + async fn send(&self, message: &SendMessage) -> Result<()> { + let client = self.client.lock().clone(); + let Some(client) = client else { + anyhow::bail!("WhatsApp Web client not connected. Initialize the bot first."); + }; + + if !self.should_allow_outbound(&message.recipient) { + tracing::warn!( + "WhatsApp Web: recipient {} not in allowed list", + Self::redact_recipient(&message.recipient) + ); + return Ok(()); + } + + let to = self.recipient_to_jid(&message.recipient)?; + let outgoing = whatsapp_rust::waproto::whatsapp::Message { + conversation: Some(message.content.clone()), + ..Default::default() + }; + + let message_id = client.send_message(to, outgoing).await?; + tracing::debug!( + "WhatsApp Web: sent message to {} (id: {})", + message.recipient, + message_id + ); + Ok(()) + } + + async fn listen(&self, tx: tokio::sync::mpsc::Sender) -> Result<()> { + *self.tx.lock() = Some(tx.clone()); + + use wacore::types::events::Event; + use whatsapp_rust::TokioRuntime; + use whatsapp_rust::bot::Bot; + use whatsapp_rust::pair_code::PairCodeOptions; + use whatsapp_rust_tokio_transport::TokioWebSocketTransportFactory; + use whatsapp_rust_ureq_http_client::UreqHttpClient; + + tracing::info!( + "WhatsApp Web channel starting (session: {})", + self.session_path + ); + + // Upstream's Diesel-backed SqliteStore pulls a separate sqlite3 native + // link chain that conflicts with the TinyAgents 1.3 / rusqlite 0.40 + // baseline. Keep the feature buildable with wacore's full in-memory + // Backend until a rusqlite-backed durable store is added here. + tracing::warn!( + "[whatsapp_web] using in-memory WhatsApp Web session backend; \ + session_path={} is reserved but not persisted in this build", + self.session_path + ); + let backend: Arc = + Arc::new(wacore::store::InMemoryBackend::new()); + + let mut transport_factory = TokioWebSocketTransportFactory::new(); + if let Ok(ws_url) = std::env::var("WHATSAPP_WS_URL") { + transport_factory = transport_factory.with_url(ws_url); + } + + let http_client = UreqHttpClient::new(); + + let tx_for_handler = tx.clone(); + let allowed_numbers = self.allowed_numbers.clone(); + let connected_for_handler = Arc::clone(&self.connected); + let allowed_groups_for_handler = Arc::clone(&self.allowed_groups); + + let mut builder = Bot::builder() + .with_backend(backend) + .with_transport_factory(transport_factory) + .with_http_client(http_client) + .with_runtime(TokioRuntime) + .on_event(move |event, _client| { + let tx_inner = tx_for_handler.clone(); + let allowed_numbers = allowed_numbers.clone(); + let connected = Arc::clone(&connected_for_handler); + let allowed_groups = Arc::clone(&allowed_groups_for_handler); + async move { + match event { + Event::Message(msg, info) => { + // Self-echoes (messages this user sent from another + // linked device) are mirrored to all devices via + // the WhatsApp protocol. Drop them so the agent + // doesn't react to its own outgoing messages. + if info.source.is_from_me { + return; + } + + let text = Self::extract_message_text( + msg.conversation.as_deref(), + msg.extended_text_message + .as_ref() + .and_then(|e| e.text.as_deref()), + ); + + // Sender JID can use either the legacy `s.whatsapp.net` + // server (phone-number addressing) or the newer `lid` + // server (privacy-preserving identifier). Render the + // user portion in E.164 with a leading `+` for the + // allowed-list check + downstream subscriber. + let sender_user = info.source.sender.user.clone(); + let normalized = if sender_user.starts_with('+') { + sender_user.clone() + } else { + format!("+{sender_user}") + }; + let chat = info.source.chat.to_string(); + let reply_target = Self::compute_reply_target(&chat, &normalized); + + // Routine logs only carry coarse metadata — no raw + // sender identifier, no message body — so PII does + // not leak into application logs at any level. + // For DM chats `chat` is `@s.whatsapp.net`, + // which still carries the participant's phone + // number. Redact the user part so the routine + // log keeps only the server suffix (DM vs group) + // and a coarse identifier tail. + tracing::info!( + "📨 WhatsApp inbound: chat={} sender={} text_len={}", + Self::redact_recipient(&chat), + Self::redact_phone(&normalized), + text.len() + ); + + if allowed_numbers.is_empty() + || allowed_numbers.iter().any(|n| n == "*" || n == &normalized) + { + // Record group provenance: this group has had at + // least one allow-listed participant message in, + // so subsequent outbound replies into the same + // group are legitimate. Outbound to groups + // without provenance is rejected by + // `should_allow_outbound`. + if Self::is_group_jid(&chat) { + allowed_groups.lock().insert(chat.clone()); + } + if let Err(e) = tx_inner + .send(ChannelMessage { + id: uuid::Uuid::new_v4().to_string(), + channel: "whatsapp".to_string(), + sender: normalized.clone(), + reply_target, + content: text, + timestamp: chrono::Utc::now().timestamp_millis() as u64, + thread_ts: None, + }) + .await + { + tracing::error!( + "Failed to forward WhatsApp message to channel: {}", + e + ); + } + } else { + tracing::warn!( + "WhatsApp Web: message from {} not in allowed list", + Self::redact_phone(&normalized) + ); + } + } + Event::Connected(_) => { + connected.store(true, Ordering::Release); + tracing::info!("✅ WhatsApp Web connected successfully!"); + } + Event::LoggedOut(_) => { + connected.store(false, Ordering::Release); + tracing::warn!("❌ WhatsApp Web was logged out!"); + } + Event::StreamError(stream_error) => { + connected.store(false, Ordering::Release); + tracing::error!("❌ WhatsApp Web stream error: {:?}", stream_error); + } + // The pair code and QR payload are short-lived link + // credentials — anyone reading the logs while they + // are valid can hijack the session. Surface only a + // non-sensitive notice; the raw payload is never + // logged at any level. Surfacing the code to the + // user is the responsibility of an upstream UX + // path (e.g. a JSON-RPC event the frontend renders). + Event::PairingCode { .. } => { + tracing::info!( + "🔑 WhatsApp Web pair code received. Enter the code shown on \ + your linking surface into WhatsApp > Linked Devices." + ); + } + Event::PairingQrCode { .. } => { + tracing::info!( + "📱 WhatsApp Web QR code received. Render via QR generator and \ + scan with WhatsApp > Linked Devices." + ); + } + _ => {} + } + } + }); + + if let Some(ref phone) = self.pair_phone { + tracing::info!("WhatsApp Web: pair-code flow enabled for configured phone number"); + builder = builder.with_pair_code(PairCodeOptions { + phone_number: phone.clone(), + custom_code: self.pair_code.clone(), + ..Default::default() + }); + } else if self.pair_code.is_some() { + tracing::warn!( + "WhatsApp Web: pair_code is set but pair_phone is missing; pair code config is ignored" + ); + } + + let mut bot = builder.build().await?; + *self.client.lock() = Some(bot.client()); + + let bot_handle = bot.run().await?; + *self.bot_handle.lock() = Some(bot_handle); + + // Wire into the host lifecycle registry so SIGTERM and SIGINT both + // trigger a coordinated tear-down through the host runtime. The + // previous `tokio::signal::ctrl_c()` path silently ignored SIGTERM + // and bypassed the registered cleanup hooks the rest of the process + // uses. + let shutdown_notify = Arc::new(tokio::sync::Notify::new()); + if let Some(reg) = &self.lifecycle { + let bot_handle_for_hook = Arc::clone(&self.bot_handle); + let connected_for_hook = Arc::clone(&self.connected); + let client_for_hook = Arc::clone(&self.client); + let notify_for_hook = Arc::clone(&shutdown_notify); + reg.register_shutdown( + "whatsapp_web", + Box::new(move || { + let bot_handle = Arc::clone(&bot_handle_for_hook); + let connected = Arc::clone(&connected_for_hook); + let client = Arc::clone(&client_for_hook); + let notify = Arc::clone(¬ify_for_hook); + Box::pin(async move { + tracing::info!( + "[whatsapp_web] graceful shutdown hook firing — aborting bot" + ); + connected.store(false, Ordering::Release); + *client.lock() = None; + if let Some(handle) = bot_handle.lock().take() { + handle.abort(); + } + notify.notify_waiters(); + }) + }), + ); + } else { + tracing::warn!( + "[whatsapp_web] no host LifecycleRegistry provided; graceful shutdown \ + teardown will not be registered. The channel will still park until \ + process exit." + ); + } + + shutdown_notify.notified().await; + tracing::info!("WhatsApp Web channel exited via shared shutdown"); + + Ok(()) + } + + async fn health_check(&self) -> bool { + self.connected.load(Ordering::Acquire) + } + + async fn start_typing(&self, recipient: &str) -> Result<()> { + let client = self.client.lock().clone(); + let Some(client) = client else { + anyhow::bail!("WhatsApp Web client not connected. Initialize the bot first."); + }; + + if !self.should_allow_outbound(recipient) { + tracing::warn!( + "WhatsApp Web: typing target {} not in allowed list", + Self::redact_recipient(recipient) + ); + return Ok(()); + } + + let to = self.recipient_to_jid(recipient)?; + client + .chatstate() + .send_composing(&to) + .await + .map_err(|e| anyhow!("Failed to send typing state (composing): {e}"))?; + + tracing::debug!("WhatsApp Web: start typing for {}", recipient); + Ok(()) + } + + async fn stop_typing(&self, recipient: &str) -> Result<()> { + let client = self.client.lock().clone(); + let Some(client) = client else { + anyhow::bail!("WhatsApp Web client not connected. Initialize the bot first."); + }; + + if !self.should_allow_outbound(recipient) { + tracing::warn!( + "WhatsApp Web: typing target {} not in allowed list", + Self::redact_recipient(recipient) + ); + return Ok(()); + } + + let to = self.recipient_to_jid(recipient)?; + client + .chatstate() + .send_paused(&to) + .await + .map_err(|e| anyhow!("Failed to send typing state (paused): {e}"))?; + + tracing::debug!("WhatsApp Web: stop typing for {}", recipient); + Ok(()) + } +} + +// Stub implementation when the feature is not enabled. Keeps the public ctor +// signature compatible so channel startup compiles unchanged. +#[cfg(not(feature = "whatsapp-web"))] +pub struct WhatsAppWebChannel { + _private: (), + /// Retained so `startup` can call `.with_lifecycle(...)` unconditionally + /// regardless of feature flags. + lifecycle: Option>, +} + +#[cfg(not(feature = "whatsapp-web"))] +impl WhatsAppWebChannel { + pub fn new( + _session_path: String, + _pair_phone: Option, + _pair_code: Option, + _allowed_numbers: Vec, + ) -> Self { + Self { + _private: (), + lifecycle: None, + } + } + + /// No-op host lifecycle wiring for the disabled-feature stub. Kept so + /// callers can invoke `.with_lifecycle(...)` unconditionally. + pub fn with_lifecycle( + mut self, + lifecycle: std::sync::Arc, + ) -> Self { + self.lifecycle = Some(lifecycle); + self + } +} + +#[cfg(not(feature = "whatsapp-web"))] +#[async_trait] +impl Channel for WhatsAppWebChannel { + fn name(&self) -> &str { + "whatsapp" + } + + async fn send(&self, _message: &SendMessage) -> Result<()> { + anyhow::bail!( + "WhatsApp Web channel requires the 'whatsapp-web' feature. \ + Enable with: cargo build --features whatsapp-web" + ); + } + + async fn listen(&self, _tx: tokio::sync::mpsc::Sender) -> Result<()> { + anyhow::bail!( + "WhatsApp Web channel requires the 'whatsapp-web' feature. \ + Enable with: cargo build --features whatsapp-web" + ); + } + + async fn health_check(&self) -> bool { + false + } + + async fn start_typing(&self, _recipient: &str) -> Result<()> { + anyhow::bail!( + "WhatsApp Web channel requires the 'whatsapp-web' feature. \ + Enable with: cargo build --features whatsapp-web" + ); + } + + async fn stop_typing(&self, _recipient: &str) -> Result<()> { + anyhow::bail!( + "WhatsApp Web channel requires the 'whatsapp-web' feature. \ + Enable with: cargo build --features whatsapp-web" + ); + } +} + +// Every test is `#[cfg(feature = "whatsapp-web")]`, so gate the whole module +// on the feature to avoid an unused `use super::*` in the feature-off build. +#[cfg(all(test, feature = "whatsapp-web"))] +#[path = "whatsapp_web_tests.rs"] +mod tests; diff --git a/src/providers/whatsapp_web_tests.rs b/src/providers/whatsapp_web_tests.rs new file mode 100644 index 0000000..ba068f0 --- /dev/null +++ b/src/providers/whatsapp_web_tests.rs @@ -0,0 +1,254 @@ +use super::*; + +#[cfg(feature = "whatsapp-web")] +fn make_channel() -> WhatsAppWebChannel { + WhatsAppWebChannel::new( + "/tmp/test-whatsapp.db".into(), + None, + None, + vec!["+1234567890".into()], + ) +} + +#[test] +#[cfg(feature = "whatsapp-web")] +fn whatsapp_web_channel_name() { + let ch = make_channel(); + assert_eq!(ch.name(), "whatsapp"); +} + +#[test] +#[cfg(feature = "whatsapp-web")] +fn whatsapp_web_number_allowed_exact() { + let ch = make_channel(); + assert!(ch.is_number_allowed("+1234567890")); + assert!(!ch.is_number_allowed("+9876543210")); +} + +#[test] +#[cfg(feature = "whatsapp-web")] +fn whatsapp_web_number_allowed_wildcard() { + let ch = WhatsAppWebChannel::new("/tmp/test.db".into(), None, None, vec!["*".into()]); + assert!(ch.is_number_allowed("+1234567890")); + assert!(ch.is_number_allowed("+9999999999")); +} + +#[test] +#[cfg(feature = "whatsapp-web")] +fn whatsapp_web_number_denied_empty() { + let ch = WhatsAppWebChannel::new("/tmp/test.db".into(), None, None, vec![]); + // Empty allowed_numbers means "allow all" (same behavior as Cloud API) + assert!(ch.is_number_allowed("+1234567890")); +} + +#[test] +#[cfg(feature = "whatsapp-web")] +fn whatsapp_web_normalize_phone_adds_plus() { + let ch = make_channel(); + assert_eq!(ch.normalize_phone("1234567890"), "+1234567890"); +} + +#[test] +#[cfg(feature = "whatsapp-web")] +fn whatsapp_web_normalize_phone_preserves_plus() { + let ch = make_channel(); + assert_eq!(ch.normalize_phone("+1234567890"), "+1234567890"); +} + +#[tokio::test] +#[cfg(feature = "whatsapp-web")] +async fn whatsapp_web_health_check_disconnected() { + let ch = make_channel(); + assert!(!ch.health_check().await); +} + +#[tokio::test] +#[cfg(feature = "whatsapp-web")] +async fn whatsapp_web_health_check_tracks_connected_flag() { + let ch = make_channel(); + assert!(!ch.health_check().await); + ch.connected.store(true, Ordering::Release); + assert!(ch.health_check().await); + ch.connected.store(false, Ordering::Release); + assert!(!ch.health_check().await); +} + +#[test] +#[cfg(feature = "whatsapp-web")] +fn whatsapp_web_compute_reply_target_dm_pn() { + assert_eq!( + WhatsAppWebChannel::compute_reply_target("123@s.whatsapp.net", "+1234567890"), + "+1234567890" + ); +} + +#[test] +#[cfg(feature = "whatsapp-web")] +fn whatsapp_web_compute_reply_target_dm_lid() { + assert_eq!( + WhatsAppWebChannel::compute_reply_target("abc@lid", "+1234567890"), + "+1234567890" + ); +} + +#[test] +#[cfg(feature = "whatsapp-web")] +fn whatsapp_web_compute_reply_target_group() { + assert_eq!( + WhatsAppWebChannel::compute_reply_target("987654@g.us", "+1234567890"), + "987654@g.us" + ); +} + +#[test] +#[cfg(feature = "whatsapp-web")] +fn whatsapp_web_redact_phone_e164() { + assert_eq!(WhatsAppWebChannel::redact_phone("+1234567890"), "+***7890"); +} + +#[test] +#[cfg(feature = "whatsapp-web")] +fn whatsapp_web_redact_phone_no_plus() { + assert_eq!(WhatsAppWebChannel::redact_phone("1234567890"), "***7890"); +} + +#[test] +#[cfg(feature = "whatsapp-web")] +fn whatsapp_web_redact_phone_short_input() { + // Pathological short inputs collapse to a generic mask rather than + // exposing the entire identifier. + assert_eq!(WhatsAppWebChannel::redact_phone("+12"), "+****"); + assert_eq!(WhatsAppWebChannel::redact_phone("12"), "****"); +} + +#[test] +#[cfg(feature = "whatsapp-web")] +fn whatsapp_web_extract_message_text_prefers_conversation() { + assert_eq!( + WhatsAppWebChannel::extract_message_text(Some("hello"), Some("ignored")), + "hello" + ); +} + +#[test] +#[cfg(feature = "whatsapp-web")] +fn whatsapp_web_extract_message_text_falls_back_to_extended() { + assert_eq!( + WhatsAppWebChannel::extract_message_text(None, Some("from extended")), + "from extended" + ); +} + +#[test] +#[cfg(feature = "whatsapp-web")] +fn whatsapp_web_extract_message_text_empty_when_missing() { + assert_eq!(WhatsAppWebChannel::extract_message_text(None, None), ""); +} + +#[test] +#[cfg(feature = "whatsapp-web")] +fn whatsapp_web_is_group_jid_recognises_group() { + assert!(WhatsAppWebChannel::is_group_jid("123456@g.us")); + assert!(WhatsAppWebChannel::is_group_jid(" 4567@g.us ")); +} + +#[test] +#[cfg(feature = "whatsapp-web")] +fn whatsapp_web_is_group_jid_rejects_non_group() { + assert!(!WhatsAppWebChannel::is_group_jid("+1234567890")); + assert!(!WhatsAppWebChannel::is_group_jid("123@s.whatsapp.net")); + assert!(!WhatsAppWebChannel::is_group_jid("abc@lid")); + assert!(!WhatsAppWebChannel::is_group_jid("")); +} + +/// Regression for CodeRabbit finding: an `@g.us` reply target was being +/// silently dropped because the outbound path normalised the JID to +/// `+` and missed the per-number allowlist. After provenance +/// is recorded, an allowed user replying back into the group they came +/// from must succeed. +#[test] +#[cfg(feature = "whatsapp-web")] +fn whatsapp_web_should_allow_outbound_provenanced_group_allowed() { + let ch = make_channel(); // allowed_numbers = ["+1234567890"] + ch.allowed_groups + .lock() + .insert("987654321@g.us".to_string()); + assert!(ch.should_allow_outbound("987654321@g.us")); +} + +/// Regression for the follow-up CodeRabbit finding: a blanket `@g.us` +/// bypass is itself a vulnerability — a caller able to set `recipient` +/// could post into arbitrary joined groups. Groups without recorded +/// provenance must stay blocked. +#[test] +#[cfg(feature = "whatsapp-web")] +fn whatsapp_web_should_allow_outbound_unrelated_group_blocked() { + let ch = make_channel(); + ch.allowed_groups + .lock() + .insert("987654321@g.us".to_string()); + assert!(!ch.should_allow_outbound("11111@g.us")); +} + +#[test] +#[cfg(feature = "whatsapp-web")] +fn whatsapp_web_should_allow_outbound_group_without_provenance_blocked() { + let ch = make_channel(); + // empty allowed_groups + assert!(!ch.should_allow_outbound("987654321@g.us")); +} + +#[test] +#[cfg(feature = "whatsapp-web")] +fn whatsapp_web_redact_recipient_pn_jid() { + assert_eq!( + WhatsAppWebChannel::redact_recipient("1234567890@s.whatsapp.net"), + "***7890@s.whatsapp.net" + ); +} + +#[test] +#[cfg(feature = "whatsapp-web")] +fn whatsapp_web_redact_recipient_group_jid() { + assert_eq!( + WhatsAppWebChannel::redact_recipient("987654321@g.us"), + "***4321@g.us" + ); +} + +#[test] +#[cfg(feature = "whatsapp-web")] +fn whatsapp_web_redact_recipient_bare_phone() { + assert_eq!( + WhatsAppWebChannel::redact_recipient("+1234567890"), + "+***7890" + ); +} + +#[test] +#[cfg(feature = "whatsapp-web")] +fn whatsapp_web_should_allow_outbound_dm_blocks_unallowed() { + let ch = make_channel(); + assert!(!ch.should_allow_outbound("+9999999999")); +} + +#[test] +#[cfg(feature = "whatsapp-web")] +fn whatsapp_web_should_allow_outbound_dm_allows_match() { + let ch = make_channel(); + assert!(ch.should_allow_outbound("+1234567890")); +} + +#[test] +#[cfg(feature = "whatsapp-web")] +fn whatsapp_web_should_allow_outbound_wildcard_passes_dm() { + let ch = WhatsAppWebChannel::new("/tmp/t.db".into(), None, None, vec!["*".into()]); + assert!(ch.should_allow_outbound("+9999999999")); +} + +#[test] +#[cfg(feature = "whatsapp-web")] +fn whatsapp_web_should_allow_outbound_empty_allowlist_passes_dm() { + let ch = WhatsAppWebChannel::new("/tmp/t.db".into(), None, None, vec![]); + assert!(ch.should_allow_outbound("+9999999999")); +} diff --git a/src/providers/yuanbao/connection.rs b/src/providers/yuanbao/connection.rs index ef4f814..e3fd599 100644 --- a/src/providers/yuanbao/connection.rs +++ b/src/providers/yuanbao/connection.rs @@ -479,11 +479,11 @@ impl YuanbaoConnection { // Responses → match against pending requests via msg_id. if frame.cmd_type == cmd_type::RESPONSE { - if !frame.msg_id.is_empty() { - if let Some(tx) = self.pending.lock().remove(&frame.msg_id) { - let _ = tx.send(frame); - return; - } + if !frame.msg_id.is_empty() + && let Some(tx) = self.pending.lock().remove(&frame.msg_id) + { + let _ = tx.send(frame); + return; } info!( "[yuanbao] response with no waiter cmd={} msg_id={}", diff --git a/src/providers/yuanbao/cos.rs b/src/providers/yuanbao/cos.rs index 9a01704..943dd7d 100644 --- a/src/providers/yuanbao/cos.rs +++ b/src/providers/yuanbao/cos.rs @@ -192,13 +192,13 @@ pub async fn get_cos_credentials( .json() .await .map_err(|e| YuanbaoError::Media(format!("genUploadInfo body parse: {e}")))?; - if let Some(code) = payload.get("code").and_then(|c| c.as_i64()) { - if code != 0 { - return Err(YuanbaoError::Media(format!( - "genUploadInfo code={code}, msg={}", - payload.get("msg").and_then(|m| m.as_str()).unwrap_or("") - ))); - } + if let Some(code) = payload.get("code").and_then(|c| c.as_i64()) + && code != 0 + { + return Err(YuanbaoError::Media(format!( + "genUploadInfo code={code}, msg={}", + payload.get("msg").and_then(|m| m.as_str()).unwrap_or("") + ))); } let data = payload.get("data").unwrap_or(&payload); let get_str = |k: &str| -> String { diff --git a/src/providers/yuanbao/inbound.rs b/src/providers/yuanbao/inbound.rs index af3d80e..4b89b90 100644 --- a/src/providers/yuanbao/inbound.rs +++ b/src/providers/yuanbao/inbound.rs @@ -87,6 +87,7 @@ pub enum MwResult { } /// Final outcome of the whole pipeline. +#[allow(clippy::large_enum_variant)] #[derive(Debug)] pub enum PipelineOutcome { Dispatch(PipelineCtx), @@ -124,10 +125,10 @@ impl DedupCache { if self.index.contains(id) { return true; } - if self.order.len() >= self.capacity { - if let Some((old, _)) = self.order.pop_front() { - self.index.remove(&old); - } + if self.order.len() >= self.capacity + && let Some((old, _)) = self.order.pop_front() + { + self.index.remove(&old); } self.order.push_back((id.to_string(), Instant::now())); self.index.insert(id.to_string()); @@ -599,7 +600,7 @@ mod tests { // Synthesize an InboundMessagePush from scratch: use crate::providers::yuanbao::proto; let mut buf = Vec::new(); - let mut put_str = |fnum: u32, s: &str, b: &mut Vec| { + let put_str = |fnum: u32, s: &str, b: &mut Vec| { proto::encode_varint(((fnum as u64) << 3) | 2, b); proto::encode_varint(s.len() as u64, b); b.extend_from_slice(s.as_bytes()); diff --git a/src/providers/yuanbao/media.rs b/src/providers/yuanbao/media.rs index 271be11..71f69c6 100644 --- a/src/providers/yuanbao/media.rs +++ b/src/providers/yuanbao/media.rs @@ -177,19 +177,17 @@ pub async fn download_url( ) -> Result<(Vec, String), YuanbaoError> { let limit = max_size_mb.saturating_mul(1024 * 1024); - if let Ok(head) = http.head(url).send().await { - if let Some(len) = head + if let Ok(head) = http.head(url).send().await + && let Some(len) = head .headers() .get(reqwest::header::CONTENT_LENGTH) .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse::().ok()) - { - if len > limit { - return Err(YuanbaoError::Media(format!( - "remote file too large: {len} > limit {limit}" - ))); - } - } + && len > limit + { + return Err(YuanbaoError::Media(format!( + "remote file too large: {len} > limit {limit}" + ))); } let resp = http diff --git a/src/providers/yuanbao/mod.rs b/src/providers/yuanbao/mod.rs index 23e8ad9..f40dcc6 100644 --- a/src/providers/yuanbao/mod.rs +++ b/src/providers/yuanbao/mod.rs @@ -8,6 +8,11 @@ //! - [`outbound`] owns Yuanbao send/query calls. //! - [`proto`] / [`proto_biz`] / [`wire`] own hand-written protobuf codecs. +// Test setup across the yuanbao submodules builds configs via +// `Default::default()` then field assignment; the stylistic +// `field_reassign_with_default` lint isn't worth churning that code. +#![allow(clippy::field_reassign_with_default)] + pub mod channel; pub mod config; pub mod connection; diff --git a/src/providers/yuanbao/outbound.rs b/src/providers/yuanbao/outbound.rs index 04c7df8..8508ebf 100644 --- a/src/providers/yuanbao/outbound.rs +++ b/src/providers/yuanbao/outbound.rs @@ -86,12 +86,11 @@ impl OutboundSender { /// Prefers the server-issued `bot_id` cached after sign-token / auth-bind /// — matches hermes-agent `_bot_id = token_data["bot_id"]` (yuanbao.py:400). async fn resolve_from_account(&self) -> String { - if let Some(sign) = &self.sign_manager { - if let Some(entry) = sign.cached(&self.app_key).await { - if !entry.bot_id.is_empty() { - return entry.bot_id; - } - } + if let Some(sign) = &self.sign_manager + && let Some(entry) = sign.cached(&self.app_key).await + && !entry.bot_id.is_empty() + { + return entry.bot_id; } self.config_bot_id.clone() } @@ -319,10 +318,10 @@ fn random_u32() -> u32 { /// back to "file" if there's nothing usable. fn extract_filename(url_str: &str) -> String { if let Ok(parsed) = url::Url::parse(url_str) { - if let Some(segments) = parsed.path_segments() { - if let Some(last) = segments.filter(|s| !s.is_empty()).last() { - return last.to_string(); - } + if let Some(mut segments) = parsed.path_segments() + && let Some(last) = segments.rfind(|s| !s.is_empty()) + { + return last.to_string(); } return "file".to_string(); } diff --git a/src/providers/yuanbao/proto.rs b/src/providers/yuanbao/proto.rs index 1737ca3..35dd96b 100644 --- a/src/providers/yuanbao/proto.rs +++ b/src/providers/yuanbao/proto.rs @@ -211,40 +211,40 @@ pub fn encode_push_ack(original: &ConnFrame) -> Vec { pub fn encode_msg_content(c: &MsgContent) -> Vec { let mut buf = Vec::with_capacity(64); - if let Some(ref v) = c.text { - if !v.is_empty() { - encode_field_string(1, v, &mut buf); - } - } - if let Some(ref v) = c.uuid { - if !v.is_empty() { - encode_field_string(2, v, &mut buf); - } - } - if let Some(v) = c.image_format { - if v != 0 { - encode_field_varint(3, v as u64, &mut buf); - } - } - if let Some(ref v) = c.data { - if !v.is_empty() { - encode_field_string(4, v, &mut buf); - } - } - if let Some(ref v) = c.desc { - if !v.is_empty() { - encode_field_string(5, v, &mut buf); - } - } - if let Some(ref v) = c.ext { - if !v.is_empty() { - encode_field_string(6, v, &mut buf); - } - } - if let Some(ref v) = c.sound { - if !v.is_empty() { - encode_field_string(7, v, &mut buf); - } + if let Some(ref v) = c.text + && !v.is_empty() + { + encode_field_string(1, v, &mut buf); + } + if let Some(ref v) = c.uuid + && !v.is_empty() + { + encode_field_string(2, v, &mut buf); + } + if let Some(v) = c.image_format + && v != 0 + { + encode_field_varint(3, v as u64, &mut buf); + } + if let Some(ref v) = c.data + && !v.is_empty() + { + encode_field_string(4, v, &mut buf); + } + if let Some(ref v) = c.desc + && !v.is_empty() + { + encode_field_string(5, v, &mut buf); + } + if let Some(ref v) = c.ext + && !v.is_empty() + { + encode_field_string(6, v, &mut buf); + } + if let Some(ref v) = c.sound + && !v.is_empty() + { + encode_field_string(7, v, &mut buf); } for img in &c.image_info_array { let mut ib = Vec::with_capacity(48); @@ -265,25 +265,25 @@ pub fn encode_msg_content(c: &MsgContent) -> Vec { } encode_field_bytes(8, &ib, &mut buf); } - if let Some(v) = c.index { - if v != 0 { - encode_field_varint(9, v as u64, &mut buf); - } - } - if let Some(ref v) = c.url { - if !v.is_empty() { - encode_field_string(10, v, &mut buf); - } - } - if let Some(v) = c.file_size { - if v != 0 { - encode_field_varint(11, v as u64, &mut buf); - } - } - if let Some(ref v) = c.file_name { - if !v.is_empty() { - encode_field_string(12, v, &mut buf); - } + if let Some(v) = c.index + && v != 0 + { + encode_field_varint(9, v as u64, &mut buf); + } + if let Some(ref v) = c.url + && !v.is_empty() + { + encode_field_string(10, v, &mut buf); + } + if let Some(v) = c.file_size + && v != 0 + { + encode_field_varint(11, v as u64, &mut buf); + } + if let Some(ref v) = c.file_name + && !v.is_empty() + { + encode_field_string(12, v, &mut buf); } buf } diff --git a/src/providers/yuanbao/types.rs b/src/providers/yuanbao/types.rs index b2215dc..a51232a 100644 --- a/src/providers/yuanbao/types.rs +++ b/src/providers/yuanbao/types.rs @@ -142,13 +142,13 @@ impl InboundMessage { pub fn extract_text(&self) -> String { let mut out = String::new(); for el in &self.msg_body { - if el.msg_type == "TIMTextElem" { - if let Some(ref t) = el.msg_content.text { - if !out.is_empty() { - out.push('\n'); - } - out.push_str(t); + if el.msg_type == "TIMTextElem" + && let Some(ref t) = el.msg_content.text + { + if !out.is_empty() { + out.push('\n'); } + out.push_str(t); } } out @@ -164,10 +164,11 @@ impl InboundMessage { urls.push(info.url.clone()); } } - if let Some(ref url) = el.msg_content.url { - if !url.is_empty() && !urls.contains(url) { - urls.push(url.clone()); - } + if let Some(ref url) = el.msg_content.url + && !url.is_empty() + && !urls.contains(url) + { + urls.push(url.clone()); } } } diff --git a/src/providers/yuanbao/wire.rs b/src/providers/yuanbao/wire.rs index f15fa15..bcb3115 100644 --- a/src/providers/yuanbao/wire.rs +++ b/src/providers/yuanbao/wire.rs @@ -157,10 +157,10 @@ pub fn parse_fields(data: &[u8]) -> Result, YuanbaoError> pub fn get_string(fields: &[(u32, FieldValue)], num: u32) -> String { for (n, v) in fields { - if *n == num { - if let FieldValue::Bytes(b) = v { - return String::from_utf8_lossy(b).into_owned(); - } + if *n == num + && let FieldValue::Bytes(b) = v + { + return String::from_utf8_lossy(b).into_owned(); } } String::new() @@ -168,10 +168,10 @@ pub fn get_string(fields: &[(u32, FieldValue)], num: u32) -> String { pub fn get_varint(fields: &[(u32, FieldValue)], num: u32) -> u64 { for (n, v) in fields { - if *n == num { - if let FieldValue::Varint(x) = v { - return *x; - } + if *n == num + && let FieldValue::Varint(x) = v + { + return *x; } } 0 @@ -179,10 +179,10 @@ pub fn get_varint(fields: &[(u32, FieldValue)], num: u32) -> u64 { pub fn get_bytes(fields: &[(u32, FieldValue)], num: u32) -> Vec { for (n, v) in fields { - if *n == num { - if let FieldValue::Bytes(b) = v { - return b.clone(); - } + if *n == num + && let FieldValue::Bytes(b) = v + { + return b.clone(); } } Vec::new() diff --git a/src/security.rs b/src/security.rs new file mode 100644 index 0000000..f271326 --- /dev/null +++ b/src/security.rs @@ -0,0 +1,288 @@ +//! Portable first-run bind-code pairing / allowlist auth guard. +//! +//! First-connect authentication for channels (e.g. Telegram) that support +//! operator pairing. A one-time pairing code can be shown to the operator; +//! successful pairing issues a bearer token. Tokens can be persisted in config +//! so restarts don't require re-pairing. Bearer tokens are stored as SHA-256 +//! hashes so plaintext never lands in config files. + +use parking_lot::Mutex; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Instant; + +/// Maximum failed pairing attempts before lockout. +pub const MAX_PAIR_ATTEMPTS: u32 = 5; +/// Lockout duration after too many failed pairing attempts. +pub const PAIR_LOCKOUT_SECS: u64 = 300; // 5 minutes + +/// Manages pairing state for channels that use bearer-token auth after pairing. +/// +/// Bearer tokens are stored as SHA-256 hashes to prevent plaintext exposure +/// in config files. When a new token is generated, the plaintext is returned +/// to the client once, and only the hash is retained. +// TODO: I've just made this work with parking_lot but it should use either flume or tokio's async mutexes +#[derive(Debug, Clone)] +pub struct PairingGuard { + /// Whether pairing is required at all. + require_pairing: bool, + /// One-time pairing code (generated on startup, consumed on first pair). + pairing_code: Arc>>, + /// Set of SHA-256 hashed bearer tokens (persisted across restarts). + paired_tokens: Arc>>, + /// Brute-force protection: failed attempt counter + lockout time. + failed_attempts: Arc)>>, +} + +impl PairingGuard { + /// Create a new pairing guard. + /// + /// If `require_pairing` is true and no tokens exist yet, a fresh + /// pairing code is generated and returned via `pairing_code()`. + /// + /// Existing tokens are accepted in both forms: + /// - Plaintext (`zc_...`): hashed on load for backward compatibility + /// - Already hashed (64-char hex): stored as-is + pub fn new(require_pairing: bool, existing_tokens: &[String]) -> (Self, Option) { + let tokens: HashSet = existing_tokens + .iter() + .map(|t| { + if is_token_hash(t) { + t.clone() + } else { + hash_token(t) + } + }) + .collect(); + let code = if require_pairing && tokens.is_empty() { + Some(generate_code()) + } else { + None + }; + tracing::info!( + "[openhuman:pairing] Guard created: require_pairing={}, existing_tokens={}, code_generated={}", + require_pairing, + tokens.len(), + code.is_some() + ); + let guard = Self { + require_pairing, + pairing_code: Arc::new(Mutex::new(code.clone())), + paired_tokens: Arc::new(Mutex::new(tokens)), + failed_attempts: Arc::new(Mutex::new((0, None))), + }; + (guard, code) + } + + /// The one-time pairing code (only set when no tokens exist yet). + pub fn pairing_code(&self) -> Option { + self.pairing_code.lock().clone() + } + + /// Invalidate the one-time pairing code without pairing a token. + /// + /// `try_pair` consumes the code as part of a successful `/bind `, but + /// some onboarding flows bind the operator through a different signal (e.g. + /// the Telegram self-token `/start` path allowlists the first sender + /// directly). Those paths must still finish the one-time flow so the code + /// can't later be replayed by another sender who obtains it — call this once + /// the operator is bound. Idempotent; a no-op if the code is already gone. + pub fn invalidate_code(&self) { + let mut code = self.pairing_code.lock(); + if code.is_some() { + *code = None; + tracing::info!( + "[openhuman:pairing] one-time pairing code invalidated (operator onboarded)" + ); + } + } + + /// Whether pairing is required at all. + pub fn require_pairing(&self) -> bool { + self.require_pairing + } + + fn try_pair_blocking(&self, code: &str) -> Result, u64> { + // Check brute force lockout + { + let attempts = self.failed_attempts.lock(); + if let (count, Some(locked_at)) = &*attempts + && *count >= MAX_PAIR_ATTEMPTS + { + let elapsed = locked_at.elapsed().as_secs(); + if elapsed < PAIR_LOCKOUT_SECS { + tracing::warn!( + "[openhuman:pairing] Pairing locked out: {} failed attempts, {}s remaining", + count, + PAIR_LOCKOUT_SECS - elapsed + ); + return Err(PAIR_LOCKOUT_SECS - elapsed); + } + } + } + + { + let mut pairing_code = self.pairing_code.lock(); + if let Some(ref expected) = *pairing_code + && constant_time_eq(code.trim(), expected.trim()) + { + // Reset failed attempts on success + { + let mut attempts = self.failed_attempts.lock(); + *attempts = (0, None); + } + let token = generate_token(); + let mut tokens = self.paired_tokens.lock(); + tokens.insert(hash_token(&token)); + + // Consume the pairing code so it cannot be reused + *pairing_code = None; + + tracing::info!("[openhuman:pairing] Pairing successful, token issued"); + return Ok(Some(token)); + } + } + + // Increment failed attempts + { + let mut attempts = self.failed_attempts.lock(); + attempts.0 += 1; + tracing::warn!( + "[openhuman:pairing] Pairing attempt failed ({}/{})", + attempts.0, + MAX_PAIR_ATTEMPTS + ); + if attempts.0 >= MAX_PAIR_ATTEMPTS { + attempts.1 = Some(Instant::now()); + tracing::warn!("[openhuman:pairing] Max attempts reached, lockout activated"); + } + } + + Ok(None) + } + + /// Attempt to pair with the given code. Returns a bearer token on success. + /// Returns `Err(lockout_seconds)` if locked out due to brute force. + pub async fn try_pair(&self, code: &str) -> Result, u64> { + let this = self.clone(); + let code = code.to_string(); + // TODO: make this function the main one without spawning a task + let handle = tokio::task::spawn_blocking(move || this.try_pair_blocking(&code)); + + handle + .await + .expect("failed to spawn blocking task this should not happen") + } + + /// Check if a bearer token is valid (compares against stored hashes). + /// + /// Always fails closed on empty/whitespace tokens. When pairing is not required, + /// configured tokens are still honored if present; with no tokens configured, + /// every request is rejected. + pub fn is_authenticated(&self, token: &str) -> bool { + if token.trim().is_empty() { + tracing::debug!("[openhuman:pairing] is_authenticated: rejected empty bearer token"); + return false; + } + + let tokens = self.paired_tokens.lock(); + if tokens.is_empty() { + tracing::debug!( + "[openhuman:pairing] is_authenticated: no paired tokens configured (require_pairing={})", + self.require_pairing + ); + return false; + } + + let hashed = hash_token(token); + let ok = tokens.contains(&hashed); + if !ok { + tracing::debug!("[openhuman:pairing] is_authenticated: bearer token not in paired set"); + } + ok + } + + /// Returns true if pairing is satisfied (has at least one token). + pub fn is_paired(&self) -> bool { + let tokens = self.paired_tokens.lock(); + !tokens.is_empty() + } + + /// Get all paired token hashes (for persisting to config). + pub fn tokens(&self) -> Vec { + let tokens = self.paired_tokens.lock(); + tokens.iter().cloned().collect() + } +} + +/// Generate a 6-digit numeric pairing code using cryptographically secure randomness. +pub fn generate_code() -> String { + // UUID v4 uses getrandom (backed by /dev/urandom on Linux, BCryptGenRandom + // on Windows) — a CSPRNG. We extract 4 bytes from it for a uniform random + // number in [0, 1_000_000). + // + // Rejection sampling eliminates modulo bias: values above the largest + // multiple of 1_000_000 that fits in u32 are discarded and re-drawn. + // The rejection probability is ~0.02%, so this loop almost always exits + // on the first iteration. + const UPPER_BOUND: u32 = 1_000_000; + const REJECT_THRESHOLD: u32 = (u32::MAX / UPPER_BOUND) * UPPER_BOUND; + + loop { + let uuid = uuid::Uuid::new_v4(); + let bytes = uuid.as_bytes(); + let raw = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); + + if raw < REJECT_THRESHOLD { + return format!("{:06}", raw % UPPER_BOUND); + } + } +} + +/// Generate a cryptographically-adequate bearer token with 256-bit entropy. +/// +/// Uses `rand::rng()` which is backed by the OS CSPRNG +/// (/dev/urandom on Linux, BCryptGenRandom on Windows, SecRandomCopyBytes +/// on macOS). The 32 random bytes (256 bits) are hex-encoded for a +/// 64-character token, providing 256 bits of entropy. +pub fn generate_token() -> String { + use rand::RngExt as _; + let mut bytes = [0u8; 32]; + rand::rng().fill(&mut bytes); + format!("zc_{}", hex::encode(bytes)) +} + +/// SHA-256 hash a bearer token for storage. Returns lowercase hex. +pub fn hash_token(token: &str) -> String { + format!("{:x}", Sha256::digest(token.as_bytes())) +} + +/// Check if a stored value looks like a SHA-256 hash (64 hex chars) +/// rather than a plaintext token. +pub fn is_token_hash(value: &str) -> bool { + value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit()) +} + +/// Constant-time string comparison to prevent timing attacks. +/// +/// Does not short-circuit on length mismatch — always iterates over the +/// longer input to avoid leaking length information via timing. +pub fn constant_time_eq(a: &str, b: &str) -> bool { + let a = a.as_bytes(); + let b = b.as_bytes(); + + // Track length mismatch as a usize (non-zero = different lengths) + let len_diff = a.len() ^ b.len(); + + // XOR each byte, padding the shorter input with zeros. + // Iterates over max(a.len(), b.len()) to avoid timing differences. + let max_len = a.len().max(b.len()); + let mut byte_diff = 0u8; + for i in 0..max_len { + let x = *a.get(i).unwrap_or(&0); + let y = *b.get(i).unwrap_or(&0); + byte_diff |= x ^ y; + } + (len_diff == 0) & (byte_diff == 0) +}