Merge remote-tracking branch 'origin/feat/whatsapp-email-channels'
# Conflicts: # Cargo.lock # src/config/schema.rs # src/cron/mod.rs # src/security/secrets.rs # src/service/mod.rs
This commit is contained in:
commit
47c5006de4
12 changed files with 1689 additions and 143 deletions
524
Cargo.lock
generated
524
Cargo.lock
generated
|
|
@ -24,6 +24,12 @@ dependencies = [
|
|||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
|
|
@ -89,6 +95,15 @@ version = "1.0.101"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
|
||||
|
||||
[[package]]
|
||||
name = "ar_archive_writer"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b"
|
||||
dependencies = [
|
||||
"object",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
|
|
@ -112,6 +127,28 @@ version = "1.5.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.15.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.37.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.7.9"
|
||||
|
|
@ -173,9 +210,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
|||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.10.0"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
|
|
@ -211,6 +248,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
|
|
@ -261,6 +300,16 @@ dependencies = [
|
|||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chumsky"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
"stacker",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
|
|
@ -312,6 +361,15 @@ version = "1.0.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.4"
|
||||
|
|
@ -331,6 +389,16 @@ dependencies = [
|
|||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
|
|
@ -353,7 +421,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "6f8c3e73077b4b4a6ab1ea5047c37c57aee77657bc8ecd6f29b0af082d0b0c07"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"nom",
|
||||
"nom 7.1.3",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
|
|
@ -452,6 +520,28 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "email-encoding"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||
|
||||
[[package]]
|
||||
name = "encode_unicode"
|
||||
version = "1.0.0"
|
||||
|
|
@ -498,6 +588,27 @@ version = "0.1.9"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
|
|
@ -507,6 +618,12 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.31"
|
||||
|
|
@ -615,6 +732,19 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
|
|
@ -622,6 +752,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"allocator-api2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -630,6 +770,17 @@ version = "0.16.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "hashify"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "149e3ea90eb5a26ad354cfe3cb7f7401b9329032d0235f2687d03a35f30e5d4c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.9.1"
|
||||
|
|
@ -883,6 +1034,12 @@ dependencies = [
|
|||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
|
|
@ -912,6 +1069,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
|||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.16.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -951,6 +1110,16 @@ version = "1.0.17"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.85"
|
||||
|
|
@ -967,6 +1136,39 @@ version = "1.5.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "lettre"
|
||||
version = "0.11.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"chumsky",
|
||||
"email-encoding",
|
||||
"email_address",
|
||||
"fastrand",
|
||||
"futures-util",
|
||||
"hostname",
|
||||
"httpdate",
|
||||
"idna",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"nom 8.0.0",
|
||||
"percent-encoding",
|
||||
"quoted_printable",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"tokio",
|
||||
"url",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.182"
|
||||
|
|
@ -1018,6 +1220,15 @@ version = "0.1.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mail-parser"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f82a3d6522697593ba4c683e0a6ee5a40fee93bc1a525e3cc6eeb3da11fd8897"
|
||||
dependencies = [
|
||||
"hashify",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.7.3"
|
||||
|
|
@ -1063,6 +1274,23 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d5d26952a508f321b4d3d2e80e78fc2603eaefcdf0c30783867f19586518bdc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
|
|
@ -1073,6 +1301,15 @@ dependencies = [
|
|||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
|
|
@ -1091,6 +1328,15 @@ dependencies = [
|
|||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.37.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
|
|
@ -1109,6 +1355,50 @@ version = "0.3.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.111"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
|
|
@ -1168,6 +1458,16 @@ dependencies = [
|
|||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
|
|
@ -1177,6 +1477,16 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psm"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8"
|
||||
dependencies = [
|
||||
"ar_archive_writer",
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
|
|
@ -1241,6 +1551,12 @@ dependencies = [
|
|||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quoted_printable"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
|
|
@ -1424,6 +1740,8 @@ version = "0.23.36"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
|
|
@ -1448,6 +1766,7 @@ version = "0.103.9"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
|
|
@ -1465,6 +1784,44 @@ version = "1.0.23"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "321c8673b092a9a42605034a9879d73cb79101ed5fd117bc9a597b89b4e9e61a"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
|
|
@ -1630,6 +1987,19 @@ version = "1.2.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "stacker"
|
||||
version = "0.1.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"psm",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
|
|
@ -1680,7 +2050,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.1",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
|
|
@ -2001,9 +2371,9 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
|||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.8.1"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
|
|
@ -2017,6 +2387,12 @@ version = "0.2.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
|
|
@ -2065,11 +2441,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
|||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.20.0"
|
||||
version = "1.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f"
|
||||
checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.1",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
|
@ -2110,6 +2486,15 @@ dependencies = [
|
|||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip3"
|
||||
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.108"
|
||||
|
|
@ -2169,6 +2554,28 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
|
|
@ -2182,6 +2589,18 @@ dependencies = [
|
|||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.85"
|
||||
|
|
@ -2524,6 +2943,88 @@ name = "wit-bindgen"
|
|||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
dependencies = [
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
|
|
@ -2573,8 +3074,11 @@ dependencies = [
|
|||
"hmac",
|
||||
"hostname",
|
||||
"http-body-util",
|
||||
"lettre",
|
||||
"mail-parser",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
|
@ -2582,6 +3086,7 @@ dependencies = [
|
|||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-test",
|
||||
"tokio-tungstenite",
|
||||
"toml",
|
||||
|
|
@ -2590,6 +3095,7 @@ dependencies = [
|
|||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -64,6 +64,11 @@ console = "0.15"
|
|||
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
|
||||
futures-util = { version = "0.3", default-features = false, features = ["sink"] }
|
||||
hostname = "0.4.2"
|
||||
lettre = { version = "0.11.19", features = ["smtp-transport", "rustls-tls"] }
|
||||
mail-parser = "0.11.2"
|
||||
rustls-pki-types = "1.14.0"
|
||||
tokio-rustls = "0.26.4"
|
||||
webpki-roots = "1.0.6"
|
||||
|
||||
# HTTP server (gateway) — replaces raw TCP for proper HTTP/1.1 compliance
|
||||
axum = { version = "0.7", default-features = false, features = ["http1", "json", "tokio", "query"] }
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use crate::providers::{self, Provider};
|
|||
use crate::runtime;
|
||||
use crate::security::SecurityPolicy;
|
||||
use crate::tools;
|
||||
use crate::util::truncate_with_ellipsis;
|
||||
use anyhow::Result;
|
||||
use std::fmt::Write;
|
||||
use std::sync::Arc;
|
||||
|
|
@ -150,11 +151,7 @@ pub async fn run(
|
|||
|
||||
// Auto-save assistant response to daily log
|
||||
if config.memory.auto_save {
|
||||
let summary = if response.len() > 100 {
|
||||
format!("{}...", &response[..100])
|
||||
} else {
|
||||
response.clone()
|
||||
};
|
||||
let summary = truncate_with_ellipsis(&response, 100);
|
||||
let _ = mem
|
||||
.store("assistant_resp", &summary, MemoryCategory::Daily)
|
||||
.await;
|
||||
|
|
@ -193,11 +190,7 @@ pub async fn run(
|
|||
println!("\n{response}\n");
|
||||
|
||||
if config.memory.auto_save {
|
||||
let summary = if response.len() > 100 {
|
||||
format!("{}...", &response[..100])
|
||||
} else {
|
||||
response.clone()
|
||||
};
|
||||
let summary = truncate_with_ellipsis(&response, 100);
|
||||
let _ = mem
|
||||
.store("assistant_resp", &summary, MemoryCategory::Daily)
|
||||
.await;
|
||||
|
|
|
|||
446
src/channels/email_channel.rs
Normal file
446
src/channels/email_channel.rs
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
#![allow(clippy::uninlined_format_args)]
|
||||
#![allow(clippy::map_unwrap_or)]
|
||||
#![allow(clippy::redundant_closure_for_method_calls)]
|
||||
#![allow(clippy::cast_lossless)]
|
||||
#![allow(clippy::trim_split_whitespace)]
|
||||
#![allow(clippy::doc_link_with_quotes)]
|
||||
#![allow(clippy::doc_markdown)]
|
||||
#![allow(clippy::too_many_lines)]
|
||||
#![allow(clippy::unnecessary_map_or)]
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::{Message, SmtpTransport, Transport};
|
||||
use mail_parser::{MessageParser, MimeHeaders};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::io::Write as IoWrite;
|
||||
use std::net::TcpStream;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::{interval, sleep};
|
||||
use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::traits::{Channel, ChannelMessage};
|
||||
|
||||
/// Email channel configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EmailConfig {
|
||||
/// IMAP server hostname
|
||||
pub imap_host: String,
|
||||
/// IMAP server port (default: 993 for TLS)
|
||||
#[serde(default = "default_imap_port")]
|
||||
pub imap_port: u16,
|
||||
/// IMAP folder to poll (default: INBOX)
|
||||
#[serde(default = "default_imap_folder")]
|
||||
pub imap_folder: String,
|
||||
/// SMTP server hostname
|
||||
pub smtp_host: String,
|
||||
/// SMTP server port (default: 587 for STARTTLS)
|
||||
#[serde(default = "default_smtp_port")]
|
||||
pub smtp_port: u16,
|
||||
/// Use TLS for SMTP (default: true)
|
||||
#[serde(default = "default_true")]
|
||||
pub smtp_tls: bool,
|
||||
/// Email username for authentication
|
||||
pub username: String,
|
||||
/// Email password for authentication
|
||||
pub password: String,
|
||||
/// From address for outgoing emails
|
||||
pub from_address: String,
|
||||
/// Poll interval in seconds (default: 60)
|
||||
#[serde(default = "default_poll_interval")]
|
||||
pub poll_interval_secs: u64,
|
||||
/// Allowed sender addresses/domains (empty = deny all, ["*"] = allow all)
|
||||
#[serde(default)]
|
||||
pub allowed_senders: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_imap_port() -> u16 {
|
||||
993
|
||||
}
|
||||
fn default_smtp_port() -> u16 {
|
||||
587
|
||||
}
|
||||
fn default_imap_folder() -> String {
|
||||
"INBOX".into()
|
||||
}
|
||||
fn default_poll_interval() -> u64 {
|
||||
60
|
||||
}
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for EmailConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
imap_host: String::new(),
|
||||
imap_port: default_imap_port(),
|
||||
imap_folder: default_imap_folder(),
|
||||
smtp_host: String::new(),
|
||||
smtp_port: default_smtp_port(),
|
||||
smtp_tls: true,
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
from_address: String::new(),
|
||||
poll_interval_secs: default_poll_interval(),
|
||||
allowed_senders: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Email channel — IMAP polling for inbound, SMTP for outbound
|
||||
pub struct EmailChannel {
|
||||
pub config: EmailConfig,
|
||||
seen_messages: Mutex<HashSet<String>>,
|
||||
}
|
||||
|
||||
impl EmailChannel {
|
||||
pub fn new(config: EmailConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
seen_messages: Mutex::new(HashSet::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a sender email is in the allowlist
|
||||
pub fn is_sender_allowed(&self, email: &str) -> bool {
|
||||
if self.config.allowed_senders.is_empty() {
|
||||
return false; // Empty = deny all
|
||||
}
|
||||
if self.config.allowed_senders.iter().any(|a| a == "*") {
|
||||
return true; // Wildcard = allow all
|
||||
}
|
||||
let email_lower = email.to_lowercase();
|
||||
self.config.allowed_senders.iter().any(|allowed| {
|
||||
if allowed.starts_with('@') {
|
||||
// Domain match with @ prefix: "@example.com"
|
||||
email_lower.ends_with(&allowed.to_lowercase())
|
||||
} else if allowed.contains('@') {
|
||||
// Full email address match
|
||||
allowed.eq_ignore_ascii_case(email)
|
||||
} else {
|
||||
// Domain match without @ prefix: "example.com"
|
||||
email_lower.ends_with(&format!("@{}", allowed.to_lowercase()))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Strip HTML tags from content (basic)
|
||||
pub fn strip_html(html: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let mut in_tag = false;
|
||||
for ch in html.chars() {
|
||||
match ch {
|
||||
'<' => in_tag = true,
|
||||
'>' => in_tag = false,
|
||||
_ if !in_tag => result.push(ch),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
result.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// Extract the sender address from a parsed email
|
||||
fn extract_sender(parsed: &mail_parser::Message) -> String {
|
||||
parsed
|
||||
.from()
|
||||
.and_then(|addr| addr.first())
|
||||
.and_then(|a| a.address())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "unknown".into())
|
||||
}
|
||||
|
||||
/// Extract readable text from a parsed email
|
||||
fn extract_text(parsed: &mail_parser::Message) -> String {
|
||||
if let Some(text) = parsed.body_text(0) {
|
||||
return text.to_string();
|
||||
}
|
||||
if let Some(html) = parsed.body_html(0) {
|
||||
return Self::strip_html(html.as_ref());
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"(no readable content)".to_string()
|
||||
}
|
||||
|
||||
/// Fetch unseen emails via IMAP (blocking, run in spawn_blocking)
|
||||
fn fetch_unseen_imap(config: &EmailConfig) -> Result<Vec<(String, String, String, u64)>> {
|
||||
use rustls::ClientConfig as TlsConfig;
|
||||
use rustls_pki_types::ServerName;
|
||||
use std::sync::Arc;
|
||||
use tokio_rustls::rustls;
|
||||
|
||||
// Connect TCP
|
||||
let tcp = TcpStream::connect((&*config.imap_host, config.imap_port))?;
|
||||
tcp.set_read_timeout(Some(Duration::from_secs(30)))?;
|
||||
|
||||
// TLS
|
||||
let mut root_store = rustls::RootCertStore::empty();
|
||||
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
let tls_config = Arc::new(
|
||||
TlsConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth(),
|
||||
);
|
||||
let server_name: ServerName<'_> = ServerName::try_from(config.imap_host.clone())?;
|
||||
let conn = rustls::ClientConnection::new(tls_config, server_name)?;
|
||||
let mut tls = rustls::StreamOwned::new(conn, tcp);
|
||||
|
||||
let read_line =
|
||||
|tls: &mut rustls::StreamOwned<rustls::ClientConnection, TcpStream>| -> Result<String> {
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
let mut byte = [0u8; 1];
|
||||
match std::io::Read::read(tls, &mut byte) {
|
||||
Ok(0) => return Err(anyhow!("IMAP connection closed")),
|
||||
Ok(_) => {
|
||||
buf.push(byte[0]);
|
||||
if buf.ends_with(b"\r\n") {
|
||||
return Ok(String::from_utf8_lossy(&buf).to_string());
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let send_cmd = |tls: &mut rustls::StreamOwned<rustls::ClientConnection, TcpStream>,
|
||||
tag: &str,
|
||||
cmd: &str|
|
||||
-> Result<Vec<String>> {
|
||||
let full = format!("{} {}\r\n", tag, cmd);
|
||||
IoWrite::write_all(tls, full.as_bytes())?;
|
||||
IoWrite::flush(tls)?;
|
||||
let mut lines = Vec::new();
|
||||
loop {
|
||||
let line = read_line(tls)?;
|
||||
let done = line.starts_with(tag);
|
||||
lines.push(line);
|
||||
if done {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(lines)
|
||||
};
|
||||
|
||||
// Read greeting
|
||||
let _greeting = read_line(&mut tls)?;
|
||||
|
||||
// Login
|
||||
let login_resp = send_cmd(
|
||||
&mut tls,
|
||||
"A1",
|
||||
&format!("LOGIN \"{}\" \"{}\"", config.username, config.password),
|
||||
)?;
|
||||
if !login_resp.last().map_or(false, |l| l.contains("OK")) {
|
||||
return Err(anyhow!("IMAP login failed"));
|
||||
}
|
||||
|
||||
// Select folder
|
||||
let _select = send_cmd(
|
||||
&mut tls,
|
||||
"A2",
|
||||
&format!("SELECT \"{}\"", config.imap_folder),
|
||||
)?;
|
||||
|
||||
// Search unseen
|
||||
let search_resp = send_cmd(&mut tls, "A3", "SEARCH UNSEEN")?;
|
||||
let mut uids: Vec<&str> = Vec::new();
|
||||
for line in &search_resp {
|
||||
if line.starts_with("* SEARCH") {
|
||||
let parts: Vec<&str> = line.trim().split_whitespace().collect();
|
||||
if parts.len() > 2 {
|
||||
uids.extend_from_slice(&parts[2..]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut results = Vec::new();
|
||||
let mut tag_counter = 4_u32; // Start after A1, A2, A3
|
||||
|
||||
for uid in &uids {
|
||||
// Fetch RFC822 with unique tag
|
||||
let fetch_tag = format!("A{}", tag_counter);
|
||||
tag_counter += 1;
|
||||
let fetch_resp = send_cmd(&mut tls, &fetch_tag, &format!("FETCH {} RFC822", uid))?;
|
||||
// Reconstruct the raw email from the response (skip first and last lines)
|
||||
let raw: String = fetch_resp
|
||||
.iter()
|
||||
.skip(1)
|
||||
.take(fetch_resp.len().saturating_sub(2))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if let Some(parsed) = MessageParser::default().parse(raw.as_bytes()) {
|
||||
let sender = Self::extract_sender(&parsed);
|
||||
let subject = parsed.subject().unwrap_or("(no subject)").to_string();
|
||||
let body = Self::extract_text(&parsed);
|
||||
let content = format!("Subject: {}\n\n{}", subject, body);
|
||||
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),
|
||||
)
|
||||
});
|
||||
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((msg_id, sender, content, ts));
|
||||
}
|
||||
|
||||
// Mark as seen with unique tag
|
||||
let store_tag = format!("A{tag_counter}");
|
||||
tag_counter += 1;
|
||||
let _ = send_cmd(
|
||||
&mut tls,
|
||||
&store_tag,
|
||||
&format!("STORE {uid} +FLAGS (\\Seen)"),
|
||||
);
|
||||
}
|
||||
|
||||
// Logout with unique tag
|
||||
let logout_tag = format!("A{tag_counter}");
|
||||
let _ = send_cmd(&mut tls, &logout_tag, "LOGOUT");
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn create_smtp_transport(&self) -> Result<SmtpTransport> {
|
||||
let creds = Credentials::new(self.config.username.clone(), self.config.password.clone());
|
||||
let transport = if self.config.smtp_tls {
|
||||
SmtpTransport::relay(&self.config.smtp_host)?
|
||||
.port(self.config.smtp_port)
|
||||
.credentials(creds)
|
||||
.build()
|
||||
} else {
|
||||
SmtpTransport::builder_dangerous(&self.config.smtp_host)
|
||||
.port(self.config.smtp_port)
|
||||
.credentials(creds)
|
||||
.build()
|
||||
};
|
||||
Ok(transport)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for EmailChannel {
|
||||
fn name(&self) -> &str {
|
||||
"email"
|
||||
}
|
||||
|
||||
async fn send(&self, message: &str, recipient: &str) -> Result<()> {
|
||||
let (subject, body) = if message.starts_with("Subject: ") {
|
||||
if let Some(pos) = message.find('\n') {
|
||||
(&message[9..pos], message[pos + 1..].trim())
|
||||
} else {
|
||||
("ZeroClaw Message", message)
|
||||
}
|
||||
} else {
|
||||
("ZeroClaw Message", message)
|
||||
};
|
||||
|
||||
let email = Message::builder()
|
||||
.from(self.config.from_address.parse()?)
|
||||
.to(recipient.parse()?)
|
||||
.subject(subject)
|
||||
.body(body.to_string())?;
|
||||
|
||||
let transport = self.create_smtp_transport()?;
|
||||
transport.send(&email)?;
|
||||
info!("Email sent to {}", recipient);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn listen(&self, tx: mpsc::Sender<ChannelMessage>) -> Result<()> {
|
||||
info!(
|
||||
"Email polling every {}s on {}",
|
||||
self.config.poll_interval_secs, self.config.imap_folder
|
||||
);
|
||||
let mut tick = interval(Duration::from_secs(self.config.poll_interval_secs));
|
||||
let config = self.config.clone();
|
||||
|
||||
loop {
|
||||
tick.tick().await;
|
||||
let cfg = config.clone();
|
||||
match tokio::task::spawn_blocking(move || Self::fetch_unseen_imap(&cfg)).await {
|
||||
Ok(Ok(messages)) => {
|
||||
for (id, sender, content, ts) in messages {
|
||||
{
|
||||
let mut seen = self.seen_messages.lock().unwrap();
|
||||
if seen.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
if !self.is_sender_allowed(&sender) {
|
||||
warn!("Blocked email from {}", sender);
|
||||
continue;
|
||||
}
|
||||
seen.insert(id.clone());
|
||||
} // MutexGuard dropped before await
|
||||
let msg = ChannelMessage {
|
||||
id,
|
||||
sender,
|
||||
content,
|
||||
channel: "email".to_string(),
|
||||
timestamp: ts,
|
||||
};
|
||||
if tx.send(msg).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
error!("Email poll failed: {}", e);
|
||||
sleep(Duration::from_secs(10)).await;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Email poll task panicked: {}", e);
|
||||
sleep(Duration::from_secs(10)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> bool {
|
||||
let cfg = self.config.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let tcp = TcpStream::connect((&*cfg.imap_host, cfg.imap_port));
|
||||
tcp.is_ok()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
pub mod cli;
|
||||
pub mod discord;
|
||||
pub mod email_channel;
|
||||
pub mod imessage;
|
||||
pub mod matrix;
|
||||
pub mod slack;
|
||||
|
|
|
|||
|
|
@ -89,10 +89,10 @@ impl Default for IdentityConfig {
|
|||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GatewayConfig {
|
||||
/// Gateway port (default: 3000)
|
||||
/// Gateway port (default: 8080)
|
||||
#[serde(default = "default_gateway_port")]
|
||||
pub port: u16,
|
||||
/// Gateway host/bind address (default: 127.0.0.1)
|
||||
/// Gateway host (default: 127.0.0.1)
|
||||
#[serde(default = "default_gateway_host")]
|
||||
pub host: String,
|
||||
/// Require pairing before accepting requests (default: true)
|
||||
|
|
@ -178,13 +178,13 @@ impl Default for SecretsConfig {
|
|||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct BrowserConfig {
|
||||
/// Enable browser tools (`browser_open` and browser automation)
|
||||
/// Enable `browser_open` tool (opens URLs in Brave without scraping)
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// Allowed domains for browser tools (exact or subdomain match)
|
||||
/// Allowed domains for `browser_open` (exact or subdomain match)
|
||||
#[serde(default)]
|
||||
pub allowed_domains: Vec<String>,
|
||||
/// Session name for agent-browser (persists state across commands)
|
||||
/// Browser session name (for agent-browser automation)
|
||||
#[serde(default)]
|
||||
pub session_name: Option<String>,
|
||||
}
|
||||
|
|
@ -604,8 +604,7 @@ pub struct WhatsAppConfig {
|
|||
pub phone_number_id: String,
|
||||
/// Webhook verify token (you define this, Meta sends it back for verification)
|
||||
pub verify_token: String,
|
||||
/// App secret from Meta Business Suite (for webhook signature verification)
|
||||
/// Can also be set via `ZEROCLAW_WHATSAPP_APP_SECRET` environment variable
|
||||
/// App secret for webhook signature verification (X-Hub-Signature-256)
|
||||
#[serde(default)]
|
||||
pub app_secret: Option<String>,
|
||||
/// Allowed phone numbers (E.164 format: +1234567890) or "*" for all
|
||||
|
|
@ -647,19 +646,10 @@ impl Default for Config {
|
|||
|
||||
impl Config {
|
||||
pub fn load_or_init() -> Result<Self> {
|
||||
// Check for workspace override from environment (Docker support)
|
||||
let zeroclaw_dir = if let Ok(workspace) = std::env::var("ZEROCLAW_WORKSPACE") {
|
||||
let ws_path = PathBuf::from(&workspace);
|
||||
ws_path
|
||||
.parent()
|
||||
.map_or_else(|| PathBuf::from(&workspace), PathBuf::from)
|
||||
} else {
|
||||
let home = UserDirs::new()
|
||||
.map(|u| u.home_dir().to_path_buf())
|
||||
.context("Could not find home directory")?;
|
||||
home.join(".zeroclaw")
|
||||
};
|
||||
|
||||
let zeroclaw_dir = home.join(".zeroclaw");
|
||||
let config_path = zeroclaw_dir.join("config.toml");
|
||||
|
||||
if !zeroclaw_dir.exists() {
|
||||
|
|
@ -668,35 +658,20 @@ impl Config {
|
|||
.context("Failed to create workspace directory")?;
|
||||
}
|
||||
|
||||
let mut config = if config_path.exists() {
|
||||
if config_path.exists() {
|
||||
let contents =
|
||||
fs::read_to_string(&config_path).context("Failed to read config file")?;
|
||||
toml::from_str(&contents).context("Failed to parse config file")?
|
||||
let config: Config =
|
||||
toml::from_str(&contents).context("Failed to parse config file")?;
|
||||
Ok(config)
|
||||
} else {
|
||||
Config::default()
|
||||
};
|
||||
|
||||
// Apply environment variable overrides (Docker/container support)
|
||||
config.apply_env_overrides();
|
||||
|
||||
// Save config if it didn't exist (creates default config with env overrides)
|
||||
if !config_path.exists() {
|
||||
let config = Config::default();
|
||||
config.save()?;
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply environment variable overrides to config.
|
||||
///
|
||||
/// Supports:
|
||||
/// - `ZEROCLAW_API_KEY` or `API_KEY` - LLM provider API key
|
||||
/// - `ZEROCLAW_PROVIDER` or `PROVIDER` - Provider name (openrouter, openai, anthropic, ollama)
|
||||
/// - `ZEROCLAW_MODEL` - Model name/ID
|
||||
/// - `ZEROCLAW_WORKSPACE` - Workspace directory path
|
||||
/// - `ZEROCLAW_GATEWAY_PORT` or `PORT` - Gateway server port
|
||||
/// - `ZEROCLAW_GATEWAY_HOST` or `HOST` - Gateway bind address
|
||||
/// - `ZEROCLAW_TEMPERATURE` - Default temperature (0.0-2.0)
|
||||
/// Apply environment variable overrides to config
|
||||
pub fn apply_env_overrides(&mut self) {
|
||||
// API Key: ZEROCLAW_API_KEY or API_KEY
|
||||
if let Ok(key) = std::env::var("ZEROCLAW_API_KEY").or_else(|_| std::env::var("API_KEY")) {
|
||||
|
|
@ -721,15 +696,6 @@ impl Config {
|
|||
}
|
||||
}
|
||||
|
||||
// Temperature: ZEROCLAW_TEMPERATURE
|
||||
if let Ok(temp_str) = std::env::var("ZEROCLAW_TEMPERATURE") {
|
||||
if let Ok(temp) = temp_str.parse::<f64>() {
|
||||
if (0.0..=2.0).contains(&temp) {
|
||||
self.default_temperature = temp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Workspace directory: ZEROCLAW_WORKSPACE
|
||||
if let Ok(workspace) = std::env::var("ZEROCLAW_WORKSPACE") {
|
||||
if !workspace.is_empty() {
|
||||
|
|
@ -753,6 +719,15 @@ impl Config {
|
|||
self.gateway.host = host;
|
||||
}
|
||||
}
|
||||
|
||||
// Temperature: ZEROCLAW_TEMPERATURE
|
||||
if let Ok(temp_str) = std::env::var("ZEROCLAW_TEMPERATURE") {
|
||||
if let Ok(temp) = temp_str.parse::<f64>() {
|
||||
if (0.0..=2.0).contains(&temp) {
|
||||
self.default_temperature = temp;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
|
|
@ -1193,7 +1168,7 @@ channel_id = "C123"
|
|||
access_token: "tok".into(),
|
||||
phone_number_id: "12345".into(),
|
||||
verify_token: "verify".into(),
|
||||
app_secret: Some("secret123".into()),
|
||||
app_secret: None,
|
||||
allowed_numbers: vec!["+1".into()],
|
||||
};
|
||||
let toml_str = toml::to_string(&wc).unwrap();
|
||||
|
|
@ -1482,53 +1457,49 @@ default_temperature = 0.7
|
|||
|
||||
#[test]
|
||||
fn env_override_api_key() {
|
||||
// Primary and fallback tested together to avoid env-var races.
|
||||
std::env::remove_var("ZEROCLAW_API_KEY");
|
||||
std::env::remove_var("API_KEY");
|
||||
|
||||
// Primary: ZEROCLAW_API_KEY
|
||||
let mut config = Config::default();
|
||||
assert!(config.api_key.is_none());
|
||||
|
||||
std::env::set_var("ZEROCLAW_API_KEY", "sk-test-env-key");
|
||||
config.apply_env_overrides();
|
||||
assert_eq!(config.api_key.as_deref(), Some("sk-test-env-key"));
|
||||
std::env::remove_var("ZEROCLAW_API_KEY");
|
||||
|
||||
// Fallback: API_KEY
|
||||
let mut config2 = Config::default();
|
||||
std::env::remove_var("ZEROCLAW_API_KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_api_key_fallback() {
|
||||
let mut config = Config::default();
|
||||
|
||||
std::env::remove_var("ZEROCLAW_API_KEY");
|
||||
std::env::set_var("API_KEY", "sk-fallback-key");
|
||||
config2.apply_env_overrides();
|
||||
assert_eq!(config2.api_key.as_deref(), Some("sk-fallback-key"));
|
||||
config.apply_env_overrides();
|
||||
assert_eq!(config.api_key.as_deref(), Some("sk-fallback-key"));
|
||||
|
||||
std::env::remove_var("API_KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_provider() {
|
||||
// Primary, fallback, and empty-value tested together to avoid env-var races.
|
||||
std::env::remove_var("ZEROCLAW_PROVIDER");
|
||||
std::env::remove_var("PROVIDER");
|
||||
|
||||
// Primary: ZEROCLAW_PROVIDER
|
||||
let mut config = Config::default();
|
||||
|
||||
std::env::set_var("ZEROCLAW_PROVIDER", "anthropic");
|
||||
config.apply_env_overrides();
|
||||
assert_eq!(config.default_provider.as_deref(), Some("anthropic"));
|
||||
std::env::remove_var("ZEROCLAW_PROVIDER");
|
||||
|
||||
// Fallback: PROVIDER
|
||||
let mut config2 = Config::default();
|
||||
std::env::remove_var("ZEROCLAW_PROVIDER");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_provider_fallback() {
|
||||
let mut config = Config::default();
|
||||
|
||||
std::env::remove_var("ZEROCLAW_PROVIDER");
|
||||
std::env::set_var("PROVIDER", "openai");
|
||||
config2.apply_env_overrides();
|
||||
assert_eq!(config2.default_provider.as_deref(), Some("openai"));
|
||||
std::env::remove_var("PROVIDER");
|
||||
config.apply_env_overrides();
|
||||
assert_eq!(config.default_provider.as_deref(), Some("openai"));
|
||||
|
||||
// Empty value should not override
|
||||
let mut config3 = Config::default();
|
||||
let original_provider = config3.default_provider.clone();
|
||||
std::env::set_var("ZEROCLAW_PROVIDER", "");
|
||||
config3.apply_env_overrides();
|
||||
assert_eq!(config3.default_provider, original_provider);
|
||||
std::env::remove_var("ZEROCLAW_PROVIDER");
|
||||
std::env::remove_var("PROVIDER");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1539,7 +1510,6 @@ default_temperature = 0.7
|
|||
config.apply_env_overrides();
|
||||
assert_eq!(config.default_model.as_deref(), Some("gpt-4o"));
|
||||
|
||||
// Clean up
|
||||
std::env::remove_var("ZEROCLAW_MODEL");
|
||||
}
|
||||
|
||||
|
|
@ -1551,86 +1521,111 @@ default_temperature = 0.7
|
|||
config.apply_env_overrides();
|
||||
assert_eq!(config.workspace_dir, PathBuf::from("/custom/workspace"));
|
||||
|
||||
// Clean up
|
||||
std::env::remove_var("ZEROCLAW_WORKSPACE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_gateway_port() {
|
||||
// Port, fallback, and invalid tested together to avoid env-var races.
|
||||
std::env::remove_var("ZEROCLAW_GATEWAY_PORT");
|
||||
std::env::remove_var("PORT");
|
||||
fn env_override_empty_values_ignored() {
|
||||
let mut config = Config::default();
|
||||
let original_provider = config.default_provider.clone();
|
||||
|
||||
// Primary: ZEROCLAW_GATEWAY_PORT
|
||||
std::env::set_var("ZEROCLAW_PROVIDER", "");
|
||||
config.apply_env_overrides();
|
||||
assert_eq!(config.default_provider, original_provider);
|
||||
|
||||
std::env::remove_var("ZEROCLAW_PROVIDER");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_gateway_port() {
|
||||
let mut config = Config::default();
|
||||
assert_eq!(config.gateway.port, 3000);
|
||||
|
||||
std::env::set_var("ZEROCLAW_GATEWAY_PORT", "8080");
|
||||
config.apply_env_overrides();
|
||||
assert_eq!(config.gateway.port, 8080);
|
||||
|
||||
std::env::remove_var("ZEROCLAW_GATEWAY_PORT");
|
||||
}
|
||||
|
||||
// Fallback: PORT
|
||||
let mut config2 = Config::default();
|
||||
#[test]
|
||||
fn env_override_port_fallback() {
|
||||
let mut config = Config::default();
|
||||
|
||||
std::env::remove_var("ZEROCLAW_GATEWAY_PORT");
|
||||
std::env::set_var("PORT", "9000");
|
||||
config2.apply_env_overrides();
|
||||
assert_eq!(config2.gateway.port, 9000);
|
||||
|
||||
// Invalid PORT is ignored
|
||||
let mut config3 = Config::default();
|
||||
let original_port = config3.gateway.port;
|
||||
std::env::set_var("PORT", "not_a_number");
|
||||
config3.apply_env_overrides();
|
||||
assert_eq!(config3.gateway.port, original_port);
|
||||
config.apply_env_overrides();
|
||||
assert_eq!(config.gateway.port, 9000);
|
||||
|
||||
std::env::remove_var("PORT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_gateway_host() {
|
||||
// Primary and fallback tested together to avoid env-var races.
|
||||
std::env::remove_var("ZEROCLAW_GATEWAY_HOST");
|
||||
std::env::remove_var("HOST");
|
||||
|
||||
// Primary: ZEROCLAW_GATEWAY_HOST
|
||||
let mut config = Config::default();
|
||||
assert_eq!(config.gateway.host, "127.0.0.1");
|
||||
|
||||
std::env::set_var("ZEROCLAW_GATEWAY_HOST", "0.0.0.0");
|
||||
config.apply_env_overrides();
|
||||
assert_eq!(config.gateway.host, "0.0.0.0");
|
||||
std::env::remove_var("ZEROCLAW_GATEWAY_HOST");
|
||||
|
||||
// Fallback: HOST
|
||||
let mut config2 = Config::default();
|
||||
std::env::remove_var("ZEROCLAW_GATEWAY_HOST");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_host_fallback() {
|
||||
let mut config = Config::default();
|
||||
|
||||
std::env::remove_var("ZEROCLAW_GATEWAY_HOST");
|
||||
std::env::set_var("HOST", "0.0.0.0");
|
||||
config2.apply_env_overrides();
|
||||
assert_eq!(config2.gateway.host, "0.0.0.0");
|
||||
config.apply_env_overrides();
|
||||
assert_eq!(config.gateway.host, "0.0.0.0");
|
||||
|
||||
std::env::remove_var("HOST");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_temperature() {
|
||||
// Valid and out-of-range tested together to avoid env-var races.
|
||||
std::env::remove_var("ZEROCLAW_TEMPERATURE");
|
||||
|
||||
// Valid temperature is applied
|
||||
let mut config = Config::default();
|
||||
|
||||
std::env::set_var("ZEROCLAW_TEMPERATURE", "0.5");
|
||||
config.apply_env_overrides();
|
||||
assert!((config.default_temperature - 0.5).abs() < f64::EPSILON);
|
||||
|
||||
// Out-of-range temperature is ignored
|
||||
let mut config2 = Config::default();
|
||||
let original_temp = config2.default_temperature;
|
||||
std::env::remove_var("ZEROCLAW_TEMPERATURE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_temperature_out_of_range_ignored() {
|
||||
// Clean up any leftover env vars from other tests
|
||||
std::env::remove_var("ZEROCLAW_TEMPERATURE");
|
||||
|
||||
let mut config = Config::default();
|
||||
let original_temp = config.default_temperature;
|
||||
|
||||
// Temperature > 2.0 should be ignored
|
||||
std::env::set_var("ZEROCLAW_TEMPERATURE", "3.0");
|
||||
config2.apply_env_overrides();
|
||||
config.apply_env_overrides();
|
||||
assert!(
|
||||
(config2.default_temperature - original_temp).abs() < f64::EPSILON,
|
||||
(config.default_temperature - original_temp).abs() < f64::EPSILON,
|
||||
"Temperature 3.0 should be ignored (out of range)"
|
||||
);
|
||||
|
||||
std::env::remove_var("ZEROCLAW_TEMPERATURE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_invalid_port_ignored() {
|
||||
let mut config = Config::default();
|
||||
let original_port = config.gateway.port;
|
||||
|
||||
std::env::set_var("PORT", "not_a_number");
|
||||
config.apply_env_overrides();
|
||||
assert_eq!(config.gateway.port, original_port);
|
||||
|
||||
std::env::remove_var("PORT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_config_default_values() {
|
||||
let g = GatewayConfig::default();
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ use crate::config::Config;
|
|||
use crate::memory::{self, Memory, MemoryCategory};
|
||||
use crate::providers::{self, Provider};
|
||||
use crate::security::pairing::{constant_time_eq, is_public_bind, PairingGuard};
|
||||
use crate::util::truncate_with_ellipsis;
|
||||
use anyhow::Result;
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
|
|
@ -457,11 +458,7 @@ async fn handle_whatsapp_message(
|
|||
tracing::info!(
|
||||
"WhatsApp message from {}: {}",
|
||||
msg.sender,
|
||||
if msg.content.len() > 50 {
|
||||
format!("{}...", &msg.content[..50])
|
||||
} else {
|
||||
msg.content.clone()
|
||||
}
|
||||
truncate_with_ellipsis(&msg.content, 50)
|
||||
);
|
||||
|
||||
// Auto-save to memory
|
||||
|
|
|
|||
|
|
@ -11,10 +11,17 @@
|
|||
dead_code
|
||||
)]
|
||||
|
||||
pub mod channels;
|
||||
pub mod config;
|
||||
pub mod gateway;
|
||||
pub mod health;
|
||||
pub mod heartbeat;
|
||||
pub mod memory;
|
||||
pub mod observability;
|
||||
pub mod providers;
|
||||
pub mod runtime;
|
||||
pub mod security;
|
||||
pub mod skills;
|
||||
pub mod tools;
|
||||
pub mod tunnel;
|
||||
pub mod util;
|
||||
|
|
|
|||
|
|
@ -398,6 +398,7 @@ fn default_model_for_provider(provider: &str) -> String {
|
|||
"ollama" => "llama3.2".into(),
|
||||
"groq" => "llama-3.3-70b-versatile".into(),
|
||||
"deepseek" => "deepseek-chat".into(),
|
||||
"gemini" | "google" | "google-gemini" => "gemini-2.0-flash".into(),
|
||||
_ => "anthropic/claude-sonnet-4-20250514".into(),
|
||||
}
|
||||
}
|
||||
|
|
@ -466,7 +467,7 @@ fn setup_workspace() -> Result<(PathBuf, PathBuf)> {
|
|||
fn setup_provider() -> Result<(String, String, String)> {
|
||||
// ── Tier selection ──
|
||||
let tiers = vec![
|
||||
"⭐ Recommended (OpenRouter, Venice, Anthropic, OpenAI)",
|
||||
"⭐ Recommended (OpenRouter, Venice, Anthropic, OpenAI, Gemini)",
|
||||
"⚡ Fast inference (Groq, Fireworks, Together AI)",
|
||||
"🌐 Gateway / proxy (Vercel AI, Cloudflare AI, Amazon Bedrock)",
|
||||
"🔬 Specialized (Moonshot/Kimi, GLM/Zhipu, MiniMax, Qianfan, Z.AI, Synthetic, OpenCode Zen, Cohere)",
|
||||
|
|
@ -493,6 +494,10 @@ fn setup_provider() -> Result<(String, String, String)> {
|
|||
("mistral", "Mistral — Large & Codestral"),
|
||||
("xai", "xAI — Grok 3 & 4"),
|
||||
("perplexity", "Perplexity — search-augmented AI"),
|
||||
(
|
||||
"gemini",
|
||||
"Google Gemini — Gemini 2.0 Flash & Pro (supports CLI auth)",
|
||||
),
|
||||
],
|
||||
1 => vec![
|
||||
("groq", "Groq — ultra-fast LPU inference"),
|
||||
|
|
@ -575,6 +580,53 @@ fn setup_provider() -> Result<(String, String, String)> {
|
|||
let api_key = if provider_name == "ollama" {
|
||||
print_bullet("Ollama runs locally — no API key needed!");
|
||||
String::new()
|
||||
} else if provider_name == "gemini"
|
||||
|| provider_name == "google"
|
||||
|| provider_name == "google-gemini"
|
||||
{
|
||||
// Special handling for Gemini: check for CLI auth first
|
||||
if crate::providers::gemini::GeminiProvider::has_cli_credentials() {
|
||||
print_bullet(&format!(
|
||||
"{} Gemini CLI credentials detected! You can skip the API key.",
|
||||
style("✓").green().bold()
|
||||
));
|
||||
print_bullet("ZeroClaw will reuse your existing Gemini CLI authentication.");
|
||||
println!();
|
||||
|
||||
let use_cli: bool = dialoguer::Confirm::new()
|
||||
.with_prompt(" Use existing Gemini CLI authentication?")
|
||||
.default(true)
|
||||
.interact()?;
|
||||
|
||||
if use_cli {
|
||||
println!(
|
||||
" {} Using Gemini CLI OAuth tokens",
|
||||
style("✓").green().bold()
|
||||
);
|
||||
String::new() // Empty key = will use CLI tokens
|
||||
} else {
|
||||
print_bullet("Get your API key at: https://aistudio.google.com/app/apikey");
|
||||
Input::new()
|
||||
.with_prompt(" Paste your Gemini API key")
|
||||
.allow_empty(true)
|
||||
.interact_text()?
|
||||
}
|
||||
} else if std::env::var("GEMINI_API_KEY").is_ok() {
|
||||
print_bullet(&format!(
|
||||
"{} GEMINI_API_KEY environment variable detected!",
|
||||
style("✓").green().bold()
|
||||
));
|
||||
String::new()
|
||||
} else {
|
||||
print_bullet("Get your API key at: https://aistudio.google.com/app/apikey");
|
||||
print_bullet("Or run `gemini` CLI to authenticate (tokens will be reused).");
|
||||
println!();
|
||||
|
||||
Input::new()
|
||||
.with_prompt(" Paste your Gemini API key (or press Enter to skip)")
|
||||
.allow_empty(true)
|
||||
.interact_text()?
|
||||
}
|
||||
} else {
|
||||
let key_url = match provider_name {
|
||||
"openrouter" => "https://openrouter.ai/keys",
|
||||
|
|
@ -594,6 +646,7 @@ fn setup_provider() -> Result<(String, String, String)> {
|
|||
"vercel" => "https://vercel.com/account/tokens",
|
||||
"cloudflare" => "https://dash.cloudflare.com/profile/api-tokens",
|
||||
"bedrock" => "https://console.aws.amazon.com/iam",
|
||||
"gemini" | "google" | "google-gemini" => "https://aistudio.google.com/app/apikey",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
|
|
@ -735,6 +788,15 @@ fn setup_provider() -> Result<(String, String, String)> {
|
|||
("codellama", "Code Llama"),
|
||||
("phi3", "Phi-3 (small, fast)"),
|
||||
],
|
||||
"gemini" | "google" | "google-gemini" => vec![
|
||||
("gemini-2.0-flash", "Gemini 2.0 Flash (fast, recommended)"),
|
||||
(
|
||||
"gemini-2.0-flash-lite",
|
||||
"Gemini 2.0 Flash Lite (fastest, cheapest)",
|
||||
),
|
||||
("gemini-1.5-pro", "Gemini 1.5 Pro (best quality)"),
|
||||
("gemini-1.5-flash", "Gemini 1.5 Flash (balanced)"),
|
||||
],
|
||||
_ => vec![("default", "Default model")],
|
||||
};
|
||||
|
||||
|
|
@ -783,6 +845,7 @@ fn provider_env_var(name: &str) -> &'static str {
|
|||
"vercel" | "vercel-ai" => "VERCEL_API_KEY",
|
||||
"cloudflare" | "cloudflare-ai" => "CLOUDFLARE_API_KEY",
|
||||
"bedrock" | "aws-bedrock" => "AWS_ACCESS_KEY_ID",
|
||||
"gemini" | "google" | "google-gemini" => "GEMINI_API_KEY",
|
||||
_ => "API_KEY",
|
||||
}
|
||||
}
|
||||
|
|
@ -1619,8 +1682,8 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
|||
access_token: access_token.trim().to_string(),
|
||||
phone_number_id: phone_number_id.trim().to_string(),
|
||||
verify_token: verify_token.trim().to_string(),
|
||||
app_secret: None, // Can be set via ZEROCLAW_WHATSAPP_APP_SECRET env var
|
||||
allowed_numbers,
|
||||
app_secret: None, // Can be set via ZEROCLAW_WHATSAPP_APP_SECRET env var
|
||||
});
|
||||
}
|
||||
6 => {
|
||||
|
|
|
|||
385
src/providers/gemini.rs
Normal file
385
src/providers/gemini.rs
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
//! Google Gemini provider with support for:
|
||||
//! - Direct API key (`GEMINI_API_KEY` env var or config)
|
||||
//! - Gemini CLI OAuth tokens (reuse existing ~/.gemini/ authentication)
|
||||
//! - Google Cloud ADC (`GOOGLE_APPLICATION_CREDENTIALS`)
|
||||
|
||||
use crate::providers::traits::Provider;
|
||||
use async_trait::async_trait;
|
||||
use directories::UserDirs;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Gemini provider supporting multiple authentication methods.
|
||||
pub struct GeminiProvider {
|
||||
api_key: Option<String>,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// API REQUEST/RESPONSE TYPES
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct GenerateContentRequest {
|
||||
contents: Vec<Content>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
system_instruction: Option<Content>,
|
||||
#[serde(rename = "generationConfig")]
|
||||
generation_config: GenerationConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Content {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
role: Option<String>,
|
||||
parts: Vec<Part>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Part {
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct GenerationConfig {
|
||||
temperature: f64,
|
||||
#[serde(rename = "maxOutputTokens")]
|
||||
max_output_tokens: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GenerateContentResponse {
|
||||
candidates: Option<Vec<Candidate>>,
|
||||
error: Option<ApiError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Candidate {
|
||||
content: CandidateContent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CandidateContent {
|
||||
parts: Vec<ResponsePart>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResponsePart {
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiError {
|
||||
message: String,
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// GEMINI CLI TOKEN STRUCTURES
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// OAuth token stored by Gemini CLI in `~/.gemini/oauth_creds.json`
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GeminiCliOAuthCreds {
|
||||
access_token: Option<String>,
|
||||
refresh_token: Option<String>,
|
||||
expiry: Option<String>,
|
||||
}
|
||||
|
||||
/// Settings stored by Gemini CLI in ~/.gemini/settings.json
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GeminiCliSettings {
|
||||
#[serde(rename = "selectedAuthType")]
|
||||
selected_auth_type: Option<String>,
|
||||
}
|
||||
|
||||
impl GeminiProvider {
|
||||
/// Create a new Gemini provider.
|
||||
///
|
||||
/// Authentication priority:
|
||||
/// 1. Explicit API key passed in
|
||||
/// 2. `GEMINI_API_KEY` environment variable
|
||||
/// 3. `GOOGLE_API_KEY` environment variable
|
||||
/// 4. Gemini CLI OAuth tokens (`~/.gemini/oauth_creds.json`)
|
||||
pub fn new(api_key: Option<&str>) -> Self {
|
||||
let resolved_key = api_key
|
||||
.map(String::from)
|
||||
.or_else(|| std::env::var("GEMINI_API_KEY").ok())
|
||||
.or_else(|| std::env::var("GOOGLE_API_KEY").ok())
|
||||
.or_else(Self::try_load_gemini_cli_token);
|
||||
|
||||
Self {
|
||||
api_key: resolved_key,
|
||||
client: Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to load OAuth access token from Gemini CLI's cached credentials.
|
||||
/// Location: `~/.gemini/oauth_creds.json`
|
||||
fn try_load_gemini_cli_token() -> Option<String> {
|
||||
let gemini_dir = Self::gemini_cli_dir()?;
|
||||
let creds_path = gemini_dir.join("oauth_creds.json");
|
||||
|
||||
if !creds_path.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&creds_path).ok()?;
|
||||
let creds: GeminiCliOAuthCreds = serde_json::from_str(&content).ok()?;
|
||||
|
||||
// Check if token is expired (basic check)
|
||||
if let Some(ref expiry) = creds.expiry {
|
||||
if let Ok(expiry_time) = chrono::DateTime::parse_from_rfc3339(expiry) {
|
||||
if expiry_time < chrono::Utc::now() {
|
||||
tracing::debug!("Gemini CLI OAuth token expired, skipping");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
creds.access_token
|
||||
}
|
||||
|
||||
/// Get the Gemini CLI config directory (~/.gemini)
|
||||
fn gemini_cli_dir() -> Option<PathBuf> {
|
||||
UserDirs::new().map(|u| u.home_dir().join(".gemini"))
|
||||
}
|
||||
|
||||
/// Check if Gemini CLI is configured and has valid credentials
|
||||
pub fn has_cli_credentials() -> bool {
|
||||
Self::try_load_gemini_cli_token().is_some()
|
||||
}
|
||||
|
||||
/// Check if any Gemini authentication is available
|
||||
pub fn has_any_auth() -> bool {
|
||||
std::env::var("GEMINI_API_KEY").is_ok()
|
||||
|| std::env::var("GOOGLE_API_KEY").is_ok()
|
||||
|| Self::has_cli_credentials()
|
||||
}
|
||||
|
||||
/// Get authentication source description for diagnostics
|
||||
pub fn auth_source(&self) -> &'static str {
|
||||
if self.api_key.is_none() {
|
||||
return "none";
|
||||
}
|
||||
if std::env::var("GEMINI_API_KEY").is_ok() {
|
||||
return "GEMINI_API_KEY env var";
|
||||
}
|
||||
if std::env::var("GOOGLE_API_KEY").is_ok() {
|
||||
return "GOOGLE_API_KEY env var";
|
||||
}
|
||||
if Self::has_cli_credentials() {
|
||||
return "Gemini CLI OAuth";
|
||||
}
|
||||
"config"
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for GeminiProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
system_prompt: Option<&str>,
|
||||
message: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
let api_key = self.api_key.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Gemini API key not found. Options:\n\
|
||||
1. Set GEMINI_API_KEY env var\n\
|
||||
2. Run `gemini` CLI to authenticate (tokens will be reused)\n\
|
||||
3. Get an API key from https://aistudio.google.com/app/apikey\n\
|
||||
4. Run `zeroclaw onboard` to configure"
|
||||
)
|
||||
})?;
|
||||
|
||||
// Build request
|
||||
let system_instruction = system_prompt.map(|sys| Content {
|
||||
role: None,
|
||||
parts: vec![Part {
|
||||
text: sys.to_string(),
|
||||
}],
|
||||
});
|
||||
|
||||
let request = GenerateContentRequest {
|
||||
contents: vec![Content {
|
||||
role: Some("user".to_string()),
|
||||
parts: vec![Part {
|
||||
text: message.to_string(),
|
||||
}],
|
||||
}],
|
||||
system_instruction,
|
||||
generation_config: GenerationConfig {
|
||||
temperature,
|
||||
max_output_tokens: 8192,
|
||||
},
|
||||
};
|
||||
|
||||
// Gemini API endpoint
|
||||
// Model format: gemini-2.0-flash, gemini-1.5-pro, etc.
|
||||
let model_name = if model.starts_with("models/") {
|
||||
model.to_string()
|
||||
} else {
|
||||
format!("models/{model}")
|
||||
};
|
||||
|
||||
let url = format!(
|
||||
"https://generativelanguage.googleapis.com/v1beta/{model_name}:generateContent?key={api_key}"
|
||||
);
|
||||
|
||||
let response = self.client.post(&url).json(&request).send().await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Gemini API error ({status}): {error_text}");
|
||||
}
|
||||
|
||||
let result: GenerateContentResponse = response.json().await?;
|
||||
|
||||
// Check for API error in response body
|
||||
if let Some(err) = result.error {
|
||||
anyhow::bail!("Gemini API error: {}", err.message);
|
||||
}
|
||||
|
||||
// Extract text from response
|
||||
result
|
||||
.candidates
|
||||
.and_then(|c| c.into_iter().next())
|
||||
.and_then(|c| c.content.parts.into_iter().next())
|
||||
.and_then(|p| p.text)
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from Gemini"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn provider_creates_without_key() {
|
||||
let provider = GeminiProvider::new(None);
|
||||
// Should not panic, just have no key
|
||||
assert!(provider.api_key.is_none() || provider.api_key.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_creates_with_key() {
|
||||
let provider = GeminiProvider::new(Some("test-api-key"));
|
||||
assert!(provider.api_key.is_some());
|
||||
assert_eq!(provider.api_key.as_deref(), Some("test-api-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_cli_dir_returns_path() {
|
||||
let dir = GeminiProvider::gemini_cli_dir();
|
||||
// Should return Some on systems with home dir
|
||||
if UserDirs::new().is_some() {
|
||||
assert!(dir.is_some());
|
||||
assert!(dir.unwrap().ends_with(".gemini"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_source_reports_correctly() {
|
||||
let provider = GeminiProvider::new(Some("explicit-key"));
|
||||
// With explicit key, should report "config" (unless CLI credentials exist)
|
||||
let source = provider.auth_source();
|
||||
// Should be either "config" or "Gemini CLI OAuth" if CLI is configured
|
||||
assert!(source == "config" || source == "Gemini CLI OAuth");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_name_formatting() {
|
||||
// Test that model names are formatted correctly
|
||||
let model = "gemini-2.0-flash";
|
||||
let formatted = if model.starts_with("models/") {
|
||||
model.to_string()
|
||||
} else {
|
||||
format!("models/{model}")
|
||||
};
|
||||
assert_eq!(formatted, "models/gemini-2.0-flash");
|
||||
|
||||
// Already prefixed
|
||||
let model2 = "models/gemini-1.5-pro";
|
||||
let formatted2 = if model2.starts_with("models/") {
|
||||
model2.to_string()
|
||||
} else {
|
||||
format!("models/{model2}")
|
||||
};
|
||||
assert_eq!(formatted2, "models/gemini-1.5-pro");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_serialization() {
|
||||
let request = GenerateContentRequest {
|
||||
contents: vec![Content {
|
||||
role: Some("user".to_string()),
|
||||
parts: vec![Part {
|
||||
text: "Hello".to_string(),
|
||||
}],
|
||||
}],
|
||||
system_instruction: Some(Content {
|
||||
role: None,
|
||||
parts: vec![Part {
|
||||
text: "You are helpful".to_string(),
|
||||
}],
|
||||
}),
|
||||
generation_config: GenerationConfig {
|
||||
temperature: 0.7,
|
||||
max_output_tokens: 8192,
|
||||
},
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&request).unwrap();
|
||||
assert!(json.contains("\"role\":\"user\""));
|
||||
assert!(json.contains("\"text\":\"Hello\""));
|
||||
assert!(json.contains("\"temperature\":0.7"));
|
||||
assert!(json.contains("\"maxOutputTokens\":8192"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_deserialization() {
|
||||
let json = r#"{
|
||||
"candidates": [{
|
||||
"content": {
|
||||
"parts": [{"text": "Hello there!"}]
|
||||
}
|
||||
}]
|
||||
}"#;
|
||||
|
||||
let response: GenerateContentResponse = serde_json::from_str(json).unwrap();
|
||||
assert!(response.candidates.is_some());
|
||||
let text = response
|
||||
.candidates
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap()
|
||||
.content
|
||||
.parts
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap()
|
||||
.text;
|
||||
assert_eq!(text, Some("Hello there!".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_response_deserialization() {
|
||||
let json = r#"{
|
||||
"error": {
|
||||
"message": "Invalid API key"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let response: GenerateContentResponse = serde_json::from_str(json).unwrap();
|
||||
assert!(response.error.is_some());
|
||||
assert_eq!(response.error.unwrap().message, "Invalid API key");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
pub mod anthropic;
|
||||
pub mod compatible;
|
||||
pub mod gemini;
|
||||
pub mod ollama;
|
||||
pub mod openai;
|
||||
pub mod openrouter;
|
||||
|
|
@ -100,6 +101,9 @@ pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result<Box<
|
|||
"ollama" => Ok(Box::new(ollama::OllamaProvider::new(
|
||||
api_key.filter(|k| !k.is_empty()),
|
||||
))),
|
||||
"gemini" | "google" | "google-gemini" => {
|
||||
Ok(Box::new(gemini::GeminiProvider::new(api_key)))
|
||||
}
|
||||
|
||||
// ── OpenAI-compatible providers ──────────────────────
|
||||
"venice" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
|
|
@ -253,6 +257,15 @@ mod tests {
|
|||
assert!(create_provider("ollama", None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_gemini() {
|
||||
assert!(create_provider("gemini", Some("test-key")).is_ok());
|
||||
assert!(create_provider("google", Some("test-key")).is_ok());
|
||||
assert!(create_provider("google-gemini", Some("test-key")).is_ok());
|
||||
// Should also work without key (will try CLI auth)
|
||||
assert!(create_provider("gemini", None).is_ok());
|
||||
}
|
||||
|
||||
// ── OpenAI-compatible providers ──────────────────────────
|
||||
|
||||
#[test]
|
||||
|
|
@ -445,6 +458,7 @@ mod tests {
|
|||
"anthropic",
|
||||
"openai",
|
||||
"ollama",
|
||||
"gemini",
|
||||
"venice",
|
||||
"vercel",
|
||||
"cloudflare",
|
||||
|
|
|
|||
134
src/util.rs
Normal file
134
src/util.rs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
//! Utility functions for ZeroClaw.
|
||||
//!
|
||||
//! This module contains reusable helper functions used across the codebase.
|
||||
|
||||
/// Truncate a string to at most `max_chars` characters, appending "..." if truncated.
|
||||
///
|
||||
/// This function safely handles multi-byte UTF-8 characters (emoji, CJK, accented characters)
|
||||
/// by using character boundaries instead of byte indices.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `s` - The string to truncate
|
||||
/// * `max_chars` - Maximum number of characters to keep (excluding "...")
|
||||
///
|
||||
/// # Returns
|
||||
/// * Original string if length <= `max_chars`
|
||||
/// * Truncated string with "..." appended if length > `max_chars`
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// use zeroclaw::util::truncate_with_ellipsis;
|
||||
///
|
||||
/// // ASCII string - no truncation needed
|
||||
/// assert_eq!(truncate_with_ellipsis("hello", 10), "hello");
|
||||
///
|
||||
/// // ASCII string - truncation needed
|
||||
/// assert_eq!(truncate_with_ellipsis("hello world", 5), "hello...");
|
||||
///
|
||||
/// // Multi-byte UTF-8 (emoji) - safe truncation
|
||||
/// assert_eq!(truncate_with_ellipsis("Hello 🦀 World", 8), "Hello 🦀...");
|
||||
/// assert_eq!(truncate_with_ellipsis("😀😀😀😀", 2), "😀😀...");
|
||||
///
|
||||
/// // Empty string
|
||||
/// assert_eq!(truncate_with_ellipsis("", 10), "");
|
||||
/// ```
|
||||
pub fn truncate_with_ellipsis(s: &str, max_chars: usize) -> String {
|
||||
match s.char_indices().nth(max_chars) {
|
||||
Some((idx, _)) => format!("{}...", &s[..idx]),
|
||||
None => s.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_truncate_ascii_no_truncation() {
|
||||
// ASCII string shorter than limit - no change
|
||||
assert_eq!(truncate_with_ellipsis("hello", 10), "hello");
|
||||
assert_eq!(truncate_with_ellipsis("hello world", 50), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_ascii_with_truncation() {
|
||||
// ASCII string longer than limit - truncates
|
||||
assert_eq!(truncate_with_ellipsis("hello world", 5), "hello...");
|
||||
assert_eq!(truncate_with_ellipsis("This is a long message", 10), "This is a ...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_empty_string() {
|
||||
assert_eq!(truncate_with_ellipsis("", 10), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_at_exact_boundary() {
|
||||
// String exactly at boundary - no truncation
|
||||
assert_eq!(truncate_with_ellipsis("hello", 5), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_emoji_single() {
|
||||
// Single emoji (4 bytes) - should not panic
|
||||
let s = "🦀";
|
||||
assert_eq!(truncate_with_ellipsis(s, 10), s);
|
||||
assert_eq!(truncate_with_ellipsis(s, 1), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_emoji_multiple() {
|
||||
// Multiple emoji - safe truncation at character boundary
|
||||
let s = "😀😀😀😀"; // 4 emoji, each 4 bytes = 16 bytes total
|
||||
assert_eq!(truncate_with_ellipsis(s, 2), "😀😀...");
|
||||
assert_eq!(truncate_with_ellipsis(s, 3), "😀😀😀...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_mixed_ascii_emoji() {
|
||||
// Mixed ASCII and emoji
|
||||
assert_eq!(truncate_with_ellipsis("Hello 🦀 World", 8), "Hello 🦀...");
|
||||
assert_eq!(truncate_with_ellipsis("Hi 😊", 10), "Hi 😊");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_cjk_characters() {
|
||||
// CJK characters (Chinese - each is 3 bytes)
|
||||
// This would panic with byte slicing: &s[..50] where s has 17 chars (51 bytes)
|
||||
let s = "这是一个测试消息用来触发崩溃的中文"; // 21 characters
|
||||
// Each character is 3 bytes, so 50 bytes is ~16 characters
|
||||
let result = truncate_with_ellipsis(s, 16);
|
||||
assert!(result.ends_with("..."));
|
||||
// Should not panic and should be valid UTF-8
|
||||
assert!(result.is_char_boundary(result.len() - 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_accented_characters() {
|
||||
// Accented characters (2 bytes each in UTF-8)
|
||||
let s = "café résumé naïve";
|
||||
assert_eq!(truncate_with_ellipsis(s, 10), "café résumé...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_unicode_edge_case() {
|
||||
// Mix of 1-byte, 2-byte, 3-byte, and 4-byte characters
|
||||
let s = "aé你好🦀"; // 1 + 1 + 2 + 2 + 4 bytes = 10 bytes, 5 chars
|
||||
assert_eq!(truncate_with_ellipsis(s, 3), "aé你好...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_long_string() {
|
||||
// Long ASCII string
|
||||
let s = "a".repeat(200);
|
||||
let result = truncate_with_ellipsis(&s, 50);
|
||||
assert_eq!(result.len(), 53); // 50 + "..."
|
||||
assert!(result.ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_zero_max_chars() {
|
||||
// Edge case: max_chars = 0
|
||||
assert_eq!(truncate_with_ellipsis("hello", 0), "...");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue