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",
|
"zerocopy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "allocator-api2"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android_system_properties"
|
name = "android_system_properties"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
|
|
@ -89,6 +95,15 @@ version = "1.0.101"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
|
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]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
|
|
@ -112,6 +127,28 @@ version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
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]]
|
[[package]]
|
||||||
name = "axum"
|
name = "axum"
|
||||||
version = "0.7.9"
|
version = "0.7.9"
|
||||||
|
|
@ -173,9 +210,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.10.0"
|
version = "2.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
|
|
@ -211,6 +248,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
|
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
|
"jobserver",
|
||||||
|
"libc",
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -261,6 +300,16 @@ dependencies = [
|
||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "cipher"
|
name = "cipher"
|
||||||
version = "0.4.4"
|
version = "0.4.4"
|
||||||
|
|
@ -312,6 +361,15 @@ version = "1.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
|
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cmake"
|
||||||
|
version = "0.1.57"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorchoice"
|
name = "colorchoice"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|
@ -331,6 +389,16 @@ dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"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]]
|
[[package]]
|
||||||
name = "core-foundation-sys"
|
name = "core-foundation-sys"
|
||||||
version = "0.8.7"
|
version = "0.8.7"
|
||||||
|
|
@ -353,7 +421,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6f8c3e73077b4b4a6ab1ea5047c37c57aee77657bc8ecd6f29b0af082d0b0c07"
|
checksum = "6f8c3e73077b4b4a6ab1ea5047c37c57aee77657bc8ecd6f29b0af082d0b0c07"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"nom",
|
"nom 7.1.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -452,6 +520,28 @@ dependencies = [
|
||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "encode_unicode"
|
name = "encode_unicode"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
|
|
@ -498,6 +588,27 @@ version = "0.1.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
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]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
|
|
@ -507,6 +618,12 @@ dependencies = [
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fs_extra"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.31"
|
version = "0.3.31"
|
||||||
|
|
@ -615,6 +732,19 @@ dependencies = [
|
||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.14.5"
|
version = "0.14.5"
|
||||||
|
|
@ -622,6 +752,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"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]]
|
[[package]]
|
||||||
|
|
@ -630,6 +770,17 @@ version = "0.16.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
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]]
|
[[package]]
|
||||||
name = "hashlink"
|
name = "hashlink"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
|
|
@ -883,6 +1034,12 @@ dependencies = [
|
||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "id-arena"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
|
|
@ -912,6 +1069,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"equivalent",
|
"equivalent",
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.16.1",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -951,6 +1110,16 @@ version = "1.0.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
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]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.85"
|
version = "0.3.85"
|
||||||
|
|
@ -967,6 +1136,39 @@ version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
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]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.182"
|
version = "0.2.182"
|
||||||
|
|
@ -1018,6 +1220,15 @@ version = "0.1.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
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]]
|
[[package]]
|
||||||
name = "matchit"
|
name = "matchit"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
|
|
@ -1063,6 +1274,23 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "nom"
|
name = "nom"
|
||||||
version = "7.1.3"
|
version = "7.1.3"
|
||||||
|
|
@ -1073,6 +1301,15 @@ dependencies = [
|
||||||
"minimal-lexical",
|
"minimal-lexical",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nom"
|
||||||
|
version = "8.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
|
|
@ -1091,6 +1328,15 @@ dependencies = [
|
||||||
"autocfg",
|
"autocfg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "object"
|
||||||
|
version = "0.37.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.3"
|
version = "1.21.3"
|
||||||
|
|
@ -1109,6 +1355,50 @@ version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
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]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|
@ -1168,6 +1458,16 @@ dependencies = [
|
||||||
"zerocopy",
|
"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]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
|
|
@ -1177,6 +1477,16 @@ dependencies = [
|
||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.9"
|
version = "0.11.9"
|
||||||
|
|
@ -1241,6 +1551,12 @@ dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quoted_printable"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "r-efi"
|
name = "r-efi"
|
||||||
version = "5.3.0"
|
version = "5.3.0"
|
||||||
|
|
@ -1424,6 +1740,8 @@ version = "0.23.36"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
|
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
|
|
@ -1448,6 +1766,7 @@ version = "0.103.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"untrusted",
|
"untrusted",
|
||||||
|
|
@ -1465,6 +1784,44 @@ version = "1.0.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
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]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.228"
|
version = "1.0.228"
|
||||||
|
|
@ -1630,6 +1987,19 @@ version = "1.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
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]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
|
|
@ -1680,7 +2050,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
|
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.4.1",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
|
|
@ -2001,9 +2371,9 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicase"
|
name = "unicase"
|
||||||
version = "2.8.1"
|
version = "2.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
|
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
|
|
@ -2017,6 +2387,12 @@ version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-xid"
|
||||||
|
version = "0.2.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "universal-hash"
|
name = "universal-hash"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
|
|
@ -2065,11 +2441,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.20.0"
|
version = "1.21.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f"
|
checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.4.1",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
@ -2110,6 +2486,15 @@ dependencies = [
|
||||||
"wit-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.108"
|
version = "0.2.108"
|
||||||
|
|
@ -2169,6 +2554,28 @@ dependencies = [
|
||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "wasm-streams"
|
name = "wasm-streams"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
|
|
@ -2182,6 +2589,18 @@ dependencies = [
|
||||||
"web-sys",
|
"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]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.85"
|
version = "0.3.85"
|
||||||
|
|
@ -2524,6 +2943,88 @@ name = "wit-bindgen"
|
||||||
version = "0.51.0"
|
version = "0.51.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
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]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "writeable"
|
||||||
|
|
@ -2573,8 +3074,11 @@ dependencies = [
|
||||||
"hmac",
|
"hmac",
|
||||||
"hostname",
|
"hostname",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
|
"lettre",
|
||||||
|
"mail-parser",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
|
|
@ -2582,6 +3086,7 @@ dependencies = [
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
"tokio-test",
|
"tokio-test",
|
||||||
"tokio-tungstenite",
|
"tokio-tungstenite",
|
||||||
"toml",
|
"toml",
|
||||||
|
|
@ -2590,6 +3095,7 @@ dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"webpki-roots 1.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,11 @@ console = "0.15"
|
||||||
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
|
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
|
||||||
futures-util = { version = "0.3", default-features = false, features = ["sink"] }
|
futures-util = { version = "0.3", default-features = false, features = ["sink"] }
|
||||||
hostname = "0.4.2"
|
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
|
# HTTP server (gateway) — replaces raw TCP for proper HTTP/1.1 compliance
|
||||||
axum = { version = "0.7", default-features = false, features = ["http1", "json", "tokio", "query"] }
|
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::runtime;
|
||||||
use crate::security::SecurityPolicy;
|
use crate::security::SecurityPolicy;
|
||||||
use crate::tools;
|
use crate::tools;
|
||||||
|
use crate::util::truncate_with_ellipsis;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -150,11 +151,7 @@ pub async fn run(
|
||||||
|
|
||||||
// Auto-save assistant response to daily log
|
// Auto-save assistant response to daily log
|
||||||
if config.memory.auto_save {
|
if config.memory.auto_save {
|
||||||
let summary = if response.len() > 100 {
|
let summary = truncate_with_ellipsis(&response, 100);
|
||||||
format!("{}...", &response[..100])
|
|
||||||
} else {
|
|
||||||
response.clone()
|
|
||||||
};
|
|
||||||
let _ = mem
|
let _ = mem
|
||||||
.store("assistant_resp", &summary, MemoryCategory::Daily)
|
.store("assistant_resp", &summary, MemoryCategory::Daily)
|
||||||
.await;
|
.await;
|
||||||
|
|
@ -193,11 +190,7 @@ pub async fn run(
|
||||||
println!("\n{response}\n");
|
println!("\n{response}\n");
|
||||||
|
|
||||||
if config.memory.auto_save {
|
if config.memory.auto_save {
|
||||||
let summary = if response.len() > 100 {
|
let summary = truncate_with_ellipsis(&response, 100);
|
||||||
format!("{}...", &response[..100])
|
|
||||||
} else {
|
|
||||||
response.clone()
|
|
||||||
};
|
|
||||||
let _ = mem
|
let _ = mem
|
||||||
.store("assistant_resp", &summary, MemoryCategory::Daily)
|
.store("assistant_resp", &summary, MemoryCategory::Daily)
|
||||||
.await;
|
.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 cli;
|
||||||
pub mod discord;
|
pub mod discord;
|
||||||
|
pub mod email_channel;
|
||||||
pub mod imessage;
|
pub mod imessage;
|
||||||
pub mod matrix;
|
pub mod matrix;
|
||||||
pub mod slack;
|
pub mod slack;
|
||||||
|
|
|
||||||
|
|
@ -89,10 +89,10 @@ impl Default for IdentityConfig {
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct GatewayConfig {
|
pub struct GatewayConfig {
|
||||||
/// Gateway port (default: 3000)
|
/// Gateway port (default: 8080)
|
||||||
#[serde(default = "default_gateway_port")]
|
#[serde(default = "default_gateway_port")]
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
/// Gateway host/bind address (default: 127.0.0.1)
|
/// Gateway host (default: 127.0.0.1)
|
||||||
#[serde(default = "default_gateway_host")]
|
#[serde(default = "default_gateway_host")]
|
||||||
pub host: String,
|
pub host: String,
|
||||||
/// Require pairing before accepting requests (default: true)
|
/// Require pairing before accepting requests (default: true)
|
||||||
|
|
@ -178,13 +178,13 @@ impl Default for SecretsConfig {
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct BrowserConfig {
|
pub struct BrowserConfig {
|
||||||
/// Enable browser tools (`browser_open` and browser automation)
|
/// Enable `browser_open` tool (opens URLs in Brave without scraping)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// Allowed domains for browser tools (exact or subdomain match)
|
/// Allowed domains for `browser_open` (exact or subdomain match)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub allowed_domains: Vec<String>,
|
pub allowed_domains: Vec<String>,
|
||||||
/// Session name for agent-browser (persists state across commands)
|
/// Browser session name (for agent-browser automation)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub session_name: Option<String>,
|
pub session_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
@ -604,8 +604,7 @@ pub struct WhatsAppConfig {
|
||||||
pub phone_number_id: String,
|
pub phone_number_id: String,
|
||||||
/// Webhook verify token (you define this, Meta sends it back for verification)
|
/// Webhook verify token (you define this, Meta sends it back for verification)
|
||||||
pub verify_token: String,
|
pub verify_token: String,
|
||||||
/// App secret from Meta Business Suite (for webhook signature verification)
|
/// App secret for webhook signature verification (X-Hub-Signature-256)
|
||||||
/// Can also be set via `ZEROCLAW_WHATSAPP_APP_SECRET` environment variable
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub app_secret: Option<String>,
|
pub app_secret: Option<String>,
|
||||||
/// Allowed phone numbers (E.164 format: +1234567890) or "*" for all
|
/// Allowed phone numbers (E.164 format: +1234567890) or "*" for all
|
||||||
|
|
@ -647,19 +646,10 @@ impl Default for Config {
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn load_or_init() -> Result<Self> {
|
pub fn load_or_init() -> Result<Self> {
|
||||||
// Check for workspace override from environment (Docker support)
|
let home = UserDirs::new()
|
||||||
let zeroclaw_dir = if let Ok(workspace) = std::env::var("ZEROCLAW_WORKSPACE") {
|
.map(|u| u.home_dir().to_path_buf())
|
||||||
let ws_path = PathBuf::from(&workspace);
|
.context("Could not find home directory")?;
|
||||||
ws_path
|
let zeroclaw_dir = home.join(".zeroclaw");
|
||||||
.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 config_path = zeroclaw_dir.join("config.toml");
|
let config_path = zeroclaw_dir.join("config.toml");
|
||||||
|
|
||||||
if !zeroclaw_dir.exists() {
|
if !zeroclaw_dir.exists() {
|
||||||
|
|
@ -668,35 +658,20 @@ impl Config {
|
||||||
.context("Failed to create workspace directory")?;
|
.context("Failed to create workspace directory")?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut config = if config_path.exists() {
|
if config_path.exists() {
|
||||||
let contents =
|
let contents =
|
||||||
fs::read_to_string(&config_path).context("Failed to read config file")?;
|
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 {
|
} else {
|
||||||
Config::default()
|
let config = 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() {
|
|
||||||
config.save()?;
|
config.save()?;
|
||||||
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(config)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply environment variable overrides to 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)
|
|
||||||
pub fn apply_env_overrides(&mut self) {
|
pub fn apply_env_overrides(&mut self) {
|
||||||
// API Key: ZEROCLAW_API_KEY or API_KEY
|
// 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")) {
|
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
|
// Workspace directory: ZEROCLAW_WORKSPACE
|
||||||
if let Ok(workspace) = std::env::var("ZEROCLAW_WORKSPACE") {
|
if let Ok(workspace) = std::env::var("ZEROCLAW_WORKSPACE") {
|
||||||
if !workspace.is_empty() {
|
if !workspace.is_empty() {
|
||||||
|
|
@ -753,6 +719,15 @@ impl Config {
|
||||||
self.gateway.host = host;
|
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<()> {
|
pub fn save(&self) -> Result<()> {
|
||||||
|
|
@ -1193,7 +1168,7 @@ channel_id = "C123"
|
||||||
access_token: "tok".into(),
|
access_token: "tok".into(),
|
||||||
phone_number_id: "12345".into(),
|
phone_number_id: "12345".into(),
|
||||||
verify_token: "verify".into(),
|
verify_token: "verify".into(),
|
||||||
app_secret: Some("secret123".into()),
|
app_secret: None,
|
||||||
allowed_numbers: vec!["+1".into()],
|
allowed_numbers: vec!["+1".into()],
|
||||||
};
|
};
|
||||||
let toml_str = toml::to_string(&wc).unwrap();
|
let toml_str = toml::to_string(&wc).unwrap();
|
||||||
|
|
@ -1482,53 +1457,49 @@ default_temperature = 0.7
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn env_override_api_key() {
|
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();
|
let mut config = Config::default();
|
||||||
assert!(config.api_key.is_none());
|
assert!(config.api_key.is_none());
|
||||||
|
|
||||||
std::env::set_var("ZEROCLAW_API_KEY", "sk-test-env-key");
|
std::env::set_var("ZEROCLAW_API_KEY", "sk-test-env-key");
|
||||||
config.apply_env_overrides();
|
config.apply_env_overrides();
|
||||||
assert_eq!(config.api_key.as_deref(), Some("sk-test-env-key"));
|
assert_eq!(config.api_key.as_deref(), Some("sk-test-env-key"));
|
||||||
std::env::remove_var("ZEROCLAW_API_KEY");
|
|
||||||
|
|
||||||
// Fallback: API_KEY
|
std::env::remove_var("ZEROCLAW_API_KEY");
|
||||||
let mut config2 = Config::default();
|
}
|
||||||
|
|
||||||
|
#[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");
|
std::env::set_var("API_KEY", "sk-fallback-key");
|
||||||
config2.apply_env_overrides();
|
config.apply_env_overrides();
|
||||||
assert_eq!(config2.api_key.as_deref(), Some("sk-fallback-key"));
|
assert_eq!(config.api_key.as_deref(), Some("sk-fallback-key"));
|
||||||
|
|
||||||
std::env::remove_var("API_KEY");
|
std::env::remove_var("API_KEY");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn env_override_provider() {
|
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();
|
let mut config = Config::default();
|
||||||
|
|
||||||
std::env::set_var("ZEROCLAW_PROVIDER", "anthropic");
|
std::env::set_var("ZEROCLAW_PROVIDER", "anthropic");
|
||||||
config.apply_env_overrides();
|
config.apply_env_overrides();
|
||||||
assert_eq!(config.default_provider.as_deref(), Some("anthropic"));
|
assert_eq!(config.default_provider.as_deref(), Some("anthropic"));
|
||||||
std::env::remove_var("ZEROCLAW_PROVIDER");
|
|
||||||
|
|
||||||
// Fallback: PROVIDER
|
std::env::remove_var("ZEROCLAW_PROVIDER");
|
||||||
let mut config2 = Config::default();
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_override_provider_fallback() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
|
||||||
|
std::env::remove_var("ZEROCLAW_PROVIDER");
|
||||||
std::env::set_var("PROVIDER", "openai");
|
std::env::set_var("PROVIDER", "openai");
|
||||||
config2.apply_env_overrides();
|
config.apply_env_overrides();
|
||||||
assert_eq!(config2.default_provider.as_deref(), Some("openai"));
|
assert_eq!(config.default_provider.as_deref(), Some("openai"));
|
||||||
std::env::remove_var("PROVIDER");
|
|
||||||
|
|
||||||
// Empty value should not override
|
std::env::remove_var("PROVIDER");
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1539,7 +1510,6 @@ default_temperature = 0.7
|
||||||
config.apply_env_overrides();
|
config.apply_env_overrides();
|
||||||
assert_eq!(config.default_model.as_deref(), Some("gpt-4o"));
|
assert_eq!(config.default_model.as_deref(), Some("gpt-4o"));
|
||||||
|
|
||||||
// Clean up
|
|
||||||
std::env::remove_var("ZEROCLAW_MODEL");
|
std::env::remove_var("ZEROCLAW_MODEL");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1551,86 +1521,111 @@ default_temperature = 0.7
|
||||||
config.apply_env_overrides();
|
config.apply_env_overrides();
|
||||||
assert_eq!(config.workspace_dir, PathBuf::from("/custom/workspace"));
|
assert_eq!(config.workspace_dir, PathBuf::from("/custom/workspace"));
|
||||||
|
|
||||||
// Clean up
|
|
||||||
std::env::remove_var("ZEROCLAW_WORKSPACE");
|
std::env::remove_var("ZEROCLAW_WORKSPACE");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn env_override_gateway_port() {
|
fn env_override_empty_values_ignored() {
|
||||||
// Port, fallback, and invalid tested together to avoid env-var races.
|
let mut config = Config::default();
|
||||||
std::env::remove_var("ZEROCLAW_GATEWAY_PORT");
|
let original_provider = config.default_provider.clone();
|
||||||
std::env::remove_var("PORT");
|
|
||||||
|
|
||||||
// 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();
|
let mut config = Config::default();
|
||||||
assert_eq!(config.gateway.port, 3000);
|
assert_eq!(config.gateway.port, 3000);
|
||||||
|
|
||||||
std::env::set_var("ZEROCLAW_GATEWAY_PORT", "8080");
|
std::env::set_var("ZEROCLAW_GATEWAY_PORT", "8080");
|
||||||
config.apply_env_overrides();
|
config.apply_env_overrides();
|
||||||
assert_eq!(config.gateway.port, 8080);
|
assert_eq!(config.gateway.port, 8080);
|
||||||
|
|
||||||
std::env::remove_var("ZEROCLAW_GATEWAY_PORT");
|
std::env::remove_var("ZEROCLAW_GATEWAY_PORT");
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback: PORT
|
#[test]
|
||||||
let mut config2 = Config::default();
|
fn env_override_port_fallback() {
|
||||||
|
let mut config = Config::default();
|
||||||
|
|
||||||
|
std::env::remove_var("ZEROCLAW_GATEWAY_PORT");
|
||||||
std::env::set_var("PORT", "9000");
|
std::env::set_var("PORT", "9000");
|
||||||
config2.apply_env_overrides();
|
config.apply_env_overrides();
|
||||||
assert_eq!(config2.gateway.port, 9000);
|
assert_eq!(config.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);
|
|
||||||
|
|
||||||
std::env::remove_var("PORT");
|
std::env::remove_var("PORT");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn env_override_gateway_host() {
|
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();
|
let mut config = Config::default();
|
||||||
assert_eq!(config.gateway.host, "127.0.0.1");
|
assert_eq!(config.gateway.host, "127.0.0.1");
|
||||||
|
|
||||||
std::env::set_var("ZEROCLAW_GATEWAY_HOST", "0.0.0.0");
|
std::env::set_var("ZEROCLAW_GATEWAY_HOST", "0.0.0.0");
|
||||||
config.apply_env_overrides();
|
config.apply_env_overrides();
|
||||||
assert_eq!(config.gateway.host, "0.0.0.0");
|
assert_eq!(config.gateway.host, "0.0.0.0");
|
||||||
std::env::remove_var("ZEROCLAW_GATEWAY_HOST");
|
|
||||||
|
|
||||||
// Fallback: HOST
|
std::env::remove_var("ZEROCLAW_GATEWAY_HOST");
|
||||||
let mut config2 = Config::default();
|
}
|
||||||
|
|
||||||
|
#[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");
|
std::env::set_var("HOST", "0.0.0.0");
|
||||||
config2.apply_env_overrides();
|
config.apply_env_overrides();
|
||||||
assert_eq!(config2.gateway.host, "0.0.0.0");
|
assert_eq!(config.gateway.host, "0.0.0.0");
|
||||||
|
|
||||||
std::env::remove_var("HOST");
|
std::env::remove_var("HOST");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn env_override_temperature() {
|
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();
|
let mut config = Config::default();
|
||||||
|
|
||||||
std::env::set_var("ZEROCLAW_TEMPERATURE", "0.5");
|
std::env::set_var("ZEROCLAW_TEMPERATURE", "0.5");
|
||||||
config.apply_env_overrides();
|
config.apply_env_overrides();
|
||||||
assert!((config.default_temperature - 0.5).abs() < f64::EPSILON);
|
assert!((config.default_temperature - 0.5).abs() < f64::EPSILON);
|
||||||
|
|
||||||
// Out-of-range temperature is ignored
|
std::env::remove_var("ZEROCLAW_TEMPERATURE");
|
||||||
let mut config2 = Config::default();
|
}
|
||||||
let original_temp = config2.default_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");
|
std::env::set_var("ZEROCLAW_TEMPERATURE", "3.0");
|
||||||
config2.apply_env_overrides();
|
config.apply_env_overrides();
|
||||||
assert!(
|
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)"
|
"Temperature 3.0 should be ignored (out of range)"
|
||||||
);
|
);
|
||||||
|
|
||||||
std::env::remove_var("ZEROCLAW_TEMPERATURE");
|
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]
|
#[test]
|
||||||
fn gateway_config_default_values() {
|
fn gateway_config_default_values() {
|
||||||
let g = GatewayConfig::default();
|
let g = GatewayConfig::default();
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ use crate::config::Config;
|
||||||
use crate::memory::{self, Memory, MemoryCategory};
|
use crate::memory::{self, Memory, MemoryCategory};
|
||||||
use crate::providers::{self, Provider};
|
use crate::providers::{self, Provider};
|
||||||
use crate::security::pairing::{constant_time_eq, is_public_bind, PairingGuard};
|
use crate::security::pairing::{constant_time_eq, is_public_bind, PairingGuard};
|
||||||
|
use crate::util::truncate_with_ellipsis;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Bytes,
|
body::Bytes,
|
||||||
|
|
@ -457,11 +458,7 @@ async fn handle_whatsapp_message(
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"WhatsApp message from {}: {}",
|
"WhatsApp message from {}: {}",
|
||||||
msg.sender,
|
msg.sender,
|
||||||
if msg.content.len() > 50 {
|
truncate_with_ellipsis(&msg.content, 50)
|
||||||
format!("{}...", &msg.content[..50])
|
|
||||||
} else {
|
|
||||||
msg.content.clone()
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Auto-save to memory
|
// Auto-save to memory
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,17 @@
|
||||||
dead_code
|
dead_code
|
||||||
)]
|
)]
|
||||||
|
|
||||||
|
pub mod channels;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod gateway;
|
||||||
|
pub mod health;
|
||||||
pub mod heartbeat;
|
pub mod heartbeat;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
pub mod observability;
|
pub mod observability;
|
||||||
pub mod providers;
|
pub mod providers;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod security;
|
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(),
|
"ollama" => "llama3.2".into(),
|
||||||
"groq" => "llama-3.3-70b-versatile".into(),
|
"groq" => "llama-3.3-70b-versatile".into(),
|
||||||
"deepseek" => "deepseek-chat".into(),
|
"deepseek" => "deepseek-chat".into(),
|
||||||
|
"gemini" | "google" | "google-gemini" => "gemini-2.0-flash".into(),
|
||||||
_ => "anthropic/claude-sonnet-4-20250514".into(),
|
_ => "anthropic/claude-sonnet-4-20250514".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -466,7 +467,7 @@ fn setup_workspace() -> Result<(PathBuf, PathBuf)> {
|
||||||
fn setup_provider() -> Result<(String, String, String)> {
|
fn setup_provider() -> Result<(String, String, String)> {
|
||||||
// ── Tier selection ──
|
// ── Tier selection ──
|
||||||
let tiers = vec![
|
let tiers = vec![
|
||||||
"⭐ Recommended (OpenRouter, Venice, Anthropic, OpenAI)",
|
"⭐ Recommended (OpenRouter, Venice, Anthropic, OpenAI, Gemini)",
|
||||||
"⚡ Fast inference (Groq, Fireworks, Together AI)",
|
"⚡ Fast inference (Groq, Fireworks, Together AI)",
|
||||||
"🌐 Gateway / proxy (Vercel AI, Cloudflare AI, Amazon Bedrock)",
|
"🌐 Gateway / proxy (Vercel AI, Cloudflare AI, Amazon Bedrock)",
|
||||||
"🔬 Specialized (Moonshot/Kimi, GLM/Zhipu, MiniMax, Qianfan, Z.AI, Synthetic, OpenCode Zen, Cohere)",
|
"🔬 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"),
|
("mistral", "Mistral — Large & Codestral"),
|
||||||
("xai", "xAI — Grok 3 & 4"),
|
("xai", "xAI — Grok 3 & 4"),
|
||||||
("perplexity", "Perplexity — search-augmented AI"),
|
("perplexity", "Perplexity — search-augmented AI"),
|
||||||
|
(
|
||||||
|
"gemini",
|
||||||
|
"Google Gemini — Gemini 2.0 Flash & Pro (supports CLI auth)",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
1 => vec![
|
1 => vec![
|
||||||
("groq", "Groq — ultra-fast LPU inference"),
|
("groq", "Groq — ultra-fast LPU inference"),
|
||||||
|
|
@ -575,6 +580,53 @@ fn setup_provider() -> Result<(String, String, String)> {
|
||||||
let api_key = if provider_name == "ollama" {
|
let api_key = if provider_name == "ollama" {
|
||||||
print_bullet("Ollama runs locally — no API key needed!");
|
print_bullet("Ollama runs locally — no API key needed!");
|
||||||
String::new()
|
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 {
|
} else {
|
||||||
let key_url = match provider_name {
|
let key_url = match provider_name {
|
||||||
"openrouter" => "https://openrouter.ai/keys",
|
"openrouter" => "https://openrouter.ai/keys",
|
||||||
|
|
@ -594,6 +646,7 @@ fn setup_provider() -> Result<(String, String, String)> {
|
||||||
"vercel" => "https://vercel.com/account/tokens",
|
"vercel" => "https://vercel.com/account/tokens",
|
||||||
"cloudflare" => "https://dash.cloudflare.com/profile/api-tokens",
|
"cloudflare" => "https://dash.cloudflare.com/profile/api-tokens",
|
||||||
"bedrock" => "https://console.aws.amazon.com/iam",
|
"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"),
|
("codellama", "Code Llama"),
|
||||||
("phi3", "Phi-3 (small, fast)"),
|
("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")],
|
_ => vec![("default", "Default model")],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -783,6 +845,7 @@ fn provider_env_var(name: &str) -> &'static str {
|
||||||
"vercel" | "vercel-ai" => "VERCEL_API_KEY",
|
"vercel" | "vercel-ai" => "VERCEL_API_KEY",
|
||||||
"cloudflare" | "cloudflare-ai" => "CLOUDFLARE_API_KEY",
|
"cloudflare" | "cloudflare-ai" => "CLOUDFLARE_API_KEY",
|
||||||
"bedrock" | "aws-bedrock" => "AWS_ACCESS_KEY_ID",
|
"bedrock" | "aws-bedrock" => "AWS_ACCESS_KEY_ID",
|
||||||
|
"gemini" | "google" | "google-gemini" => "GEMINI_API_KEY",
|
||||||
_ => "API_KEY",
|
_ => "API_KEY",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1619,8 +1682,8 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
||||||
access_token: access_token.trim().to_string(),
|
access_token: access_token.trim().to_string(),
|
||||||
phone_number_id: phone_number_id.trim().to_string(),
|
phone_number_id: phone_number_id.trim().to_string(),
|
||||||
verify_token: verify_token.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,
|
allowed_numbers,
|
||||||
|
app_secret: None, // Can be set via ZEROCLAW_WHATSAPP_APP_SECRET env var
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
6 => {
|
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 anthropic;
|
||||||
pub mod compatible;
|
pub mod compatible;
|
||||||
|
pub mod gemini;
|
||||||
pub mod ollama;
|
pub mod ollama;
|
||||||
pub mod openai;
|
pub mod openai;
|
||||||
pub mod openrouter;
|
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(
|
"ollama" => Ok(Box::new(ollama::OllamaProvider::new(
|
||||||
api_key.filter(|k| !k.is_empty()),
|
api_key.filter(|k| !k.is_empty()),
|
||||||
))),
|
))),
|
||||||
|
"gemini" | "google" | "google-gemini" => {
|
||||||
|
Ok(Box::new(gemini::GeminiProvider::new(api_key)))
|
||||||
|
}
|
||||||
|
|
||||||
// ── OpenAI-compatible providers ──────────────────────
|
// ── OpenAI-compatible providers ──────────────────────
|
||||||
"venice" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
"venice" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||||
|
|
@ -253,6 +257,15 @@ mod tests {
|
||||||
assert!(create_provider("ollama", None).is_ok());
|
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 ──────────────────────────
|
// ── OpenAI-compatible providers ──────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -445,6 +458,7 @@ mod tests {
|
||||||
"anthropic",
|
"anthropic",
|
||||||
"openai",
|
"openai",
|
||||||
"ollama",
|
"ollama",
|
||||||
|
"gemini",
|
||||||
"venice",
|
"venice",
|
||||||
"vercel",
|
"vercel",
|
||||||
"cloudflare",
|
"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