mirror of
https://github.com/RightNow-AI/openfang.git
synced 2026-07-30 23:05:08 +00:00
real email
This commit is contained in:
Generated
+279
-2
@@ -159,6 +159,15 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "ar_archive_writer"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b"
|
||||
dependencies = [
|
||||
"object",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
@@ -180,6 +189,12 @@ dependencies = [
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -424,6 +439,12 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
@@ -527,6 +548,12 @@ dependencies = [
|
||||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bufstream"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40e38929add23cdf8a366df9b0e088953150724bcbe5fc330b0d8eb3b328eec8"
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
@@ -696,6 +723,16 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "charset"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"encoding_rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.43"
|
||||
@@ -710,6 +747,16 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chumsky"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
"stacker",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
@@ -891,7 +938,7 @@ dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"core-foundation",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"foreign-types 0.5.0",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -1581,6 +1628,22 @@ version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "email-encoding"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.6"
|
||||
@@ -1785,6 +1848,15 @@ 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 0.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
@@ -1792,7 +1864,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||
dependencies = [
|
||||
"foreign-types-macros",
|
||||
"foreign-types-shared",
|
||||
"foreign-types-shared 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1806,6 +1878,12 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.3.1"
|
||||
@@ -2354,6 +2432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"allocator-api2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2416,6 +2495,17 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hostname"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.29.1"
|
||||
@@ -2710,6 +2800,31 @@ dependencies = [
|
||||
"png 0.18.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "imap"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c617c55def8c42129e0dd503f11d7ee39d73f5c7e01eff55768b3879ff1d107d"
|
||||
dependencies = [
|
||||
"base64 0.13.1",
|
||||
"bufstream",
|
||||
"chrono",
|
||||
"imap-proto",
|
||||
"lazy_static",
|
||||
"native-tls",
|
||||
"nom 5.1.3",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "imap-proto"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a6def1d5ac8975d70b3fd101d57953fe3278ef2ee5d7816cba54b1d1dfc22f"
|
||||
dependencies = [
|
||||
"nom 5.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
@@ -2991,6 +3106,48 @@ 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 = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"chumsky",
|
||||
"email-encoding",
|
||||
"email_address",
|
||||
"fastrand",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"hostname",
|
||||
"httpdate",
|
||||
"idna",
|
||||
"mime",
|
||||
"nom 8.0.0",
|
||||
"percent-encoding",
|
||||
"quoted_printable",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"url",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lexical-core"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6607c62aa161d23d17a9072cc5da0be67cdfc89d3afb1e8d9c842bebc2525ffe"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bitflags 1.3.2",
|
||||
"cfg-if",
|
||||
"ryu",
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libappindicator"
|
||||
version = "0.9.0"
|
||||
@@ -3134,6 +3291,17 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mailparse"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3da03d5980411a724e8aaf7b61a7b5e386ec55a7fb49ee3d0ff79efc7e5e7c7e"
|
||||
dependencies = [
|
||||
"charset",
|
||||
"data-encoding",
|
||||
"quoted_printable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.14.1"
|
||||
@@ -3279,6 +3447,23 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -3327,6 +3512,26 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "5.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08959a387a676302eebf4ddbcbc611da04285579f76f88ee0506c63b1a61dd4b"
|
||||
dependencies = [
|
||||
"lexical-core",
|
||||
"memchr",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nonzero_ext"
|
||||
version = "0.3.0"
|
||||
@@ -3707,6 +3912,10 @@ dependencies = [
|
||||
"futures",
|
||||
"hex",
|
||||
"hmac",
|
||||
"imap",
|
||||
"lettre",
|
||||
"mailparse",
|
||||
"native-tls",
|
||||
"openfang-types",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -3987,12 +4196,50 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"foreign-types 0.3.2",
|
||||
"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 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.111"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -4533,6 +4780,16 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psm"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8"
|
||||
dependencies = [
|
||||
"ar_archive_writer",
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pulley-interpreter"
|
||||
version = "41.0.3"
|
||||
@@ -4662,6 +4919,12 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quoted_printable"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
@@ -5159,6 +5422,7 @@ version = "0.23.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -5784,6 +6048,19 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "stacker"
|
||||
version = "0.1.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"psm",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
|
||||
+7
-1
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.7"
|
||||
version = "0.1.8"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository = "https://github.com/RightNow-AI/openfang"
|
||||
@@ -122,6 +122,12 @@ argon2 = "0.5"
|
||||
# Lightweight regex
|
||||
regex-lite = "0.1"
|
||||
|
||||
# Email (SMTP + IMAP)
|
||||
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "smtp-transport", "tokio1", "tokio1-rustls-tls"] }
|
||||
imap = "2"
|
||||
native-tls = "0.2"
|
||||
mailparse = "0.15"
|
||||
|
||||
# Testing
|
||||
tokio-test = "0.4"
|
||||
tempfile = "3"
|
||||
|
||||
@@ -1580,12 +1580,18 @@ pub async fn start_channel_bridge_with_config(
|
||||
let mut started_names = Vec::new();
|
||||
for (adapter, _) in adapters {
|
||||
let name = adapter.name().to_string();
|
||||
// Register adapter in kernel so agents can use `channel_send` tool
|
||||
kernel
|
||||
.channel_adapters
|
||||
.insert(name.clone(), adapter.clone());
|
||||
match manager.start_adapter(adapter).await {
|
||||
Ok(()) => {
|
||||
info!("{name} channel bridge started");
|
||||
started_names.push(name);
|
||||
}
|
||||
Err(e) => {
|
||||
// Remove from kernel map if start failed
|
||||
kernel.channel_adapters.remove(&name);
|
||||
error!("Failed to start {name} bridge: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7690,10 +7690,42 @@ pub async fn serve_upload(Path(file_id): Path<String>) -> impl IntoResponse {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// GET /api/approvals — List pending approval requests.
|
||||
///
|
||||
/// Transforms field names to match the dashboard template expectations:
|
||||
/// `action_summary` → `action`, `agent_id` → `agent_name`, `requested_at` → `created_at`.
|
||||
pub async fn list_approvals(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
let pending = state.kernel.approval_manager.list_pending();
|
||||
let total = pending.len();
|
||||
Json(serde_json::json!({"approvals": pending, "total": total}))
|
||||
|
||||
// Resolve agent names for display
|
||||
let registry_agents = state.kernel.registry.list();
|
||||
|
||||
let approvals: Vec<serde_json::Value> = pending
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
let agent_name = registry_agents
|
||||
.iter()
|
||||
.find(|ag| ag.id.to_string() == a.agent_id || ag.name == a.agent_id)
|
||||
.map(|ag| ag.name.as_str())
|
||||
.unwrap_or(&a.agent_id);
|
||||
serde_json::json!({
|
||||
"id": a.id,
|
||||
"agent_id": a.agent_id,
|
||||
"agent_name": agent_name,
|
||||
"tool_name": a.tool_name,
|
||||
"description": a.description,
|
||||
"action_summary": a.action_summary,
|
||||
"action": a.action_summary,
|
||||
"risk_level": a.risk_level,
|
||||
"requested_at": a.requested_at,
|
||||
"created_at": a.requested_at,
|
||||
"timeout_secs": a.timeout_secs,
|
||||
"status": "pending"
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Json(serde_json::json!({"approvals": approvals, "total": total}))
|
||||
}
|
||||
|
||||
/// POST /api/approvals — Create a manual approval request (for external systems).
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
<body x-data="app" :data-theme="theme">
|
||||
|
||||
<!-- API Key Auth Prompt -->
|
||||
<div x-show="$store.app.showAuthPrompt" style="position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px)" x-data="{ apiKeyInput: '' }">
|
||||
<div style="background:var(--bg-card,#1e1e2e);border:1px solid var(--border,#333);border-radius:12px;padding:2rem;max-width:400px;width:90%">
|
||||
<h3 style="margin:0 0 0.5rem;font-size:1.1rem">API Key Required</h3>
|
||||
<p style="color:var(--text-dim,#888);font-size:0.85rem;margin:0 0 1rem">This instance requires an API key. Enter the key from your <code>config.toml</code>.</p>
|
||||
<input type="password" x-model="apiKeyInput" placeholder="Enter API key..." @keydown.enter="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:1px solid var(--border,#333);background:var(--bg-input,#151520);color:var(--text,#e0e0e0);font-size:0.9rem;box-sizing:border-box;margin-bottom:0.75rem">
|
||||
<button @click="$store.app.submitApiKey(apiKeyInput)" style="width:100%;padding:0.6rem;border-radius:6px;border:none;background:var(--accent,#7c3aed);color:#fff;font-weight:600;cursor:pointer;font-size:0.9rem">Unlock Dashboard</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-layout" :class="{ 'focus-mode': $store.app.focusMode }">
|
||||
<!-- Sidebar -->
|
||||
<nav class="sidebar" :class="{ collapsed: sidebarCollapsed, 'mobile-open': mobileMenuOpen }">
|
||||
|
||||
@@ -87,6 +87,10 @@ function toolIcon(toolName) {
|
||||
|
||||
// Alpine.js global store
|
||||
document.addEventListener('alpine:init', function() {
|
||||
// Restore saved API key on load
|
||||
var savedKey = localStorage.getItem('openfang-api-key');
|
||||
if (savedKey) OpenFangAPI.setAuthToken(savedKey);
|
||||
|
||||
Alpine.store('app', {
|
||||
agents: [],
|
||||
connected: false,
|
||||
@@ -99,6 +103,7 @@ document.addEventListener('alpine:init', function() {
|
||||
pendingAgent: null,
|
||||
focusMode: localStorage.getItem('openfang-focus') === 'true',
|
||||
showOnboarding: false,
|
||||
showAuthPrompt: false,
|
||||
|
||||
toggleFocusMode() {
|
||||
this.focusMode = !this.focusMode;
|
||||
@@ -146,6 +151,30 @@ document.addEventListener('alpine:init', function() {
|
||||
dismissOnboarding() {
|
||||
this.showOnboarding = false;
|
||||
localStorage.setItem('openfang-onboarded', 'true');
|
||||
},
|
||||
|
||||
async checkAuth() {
|
||||
try {
|
||||
await OpenFangAPI.get('/api/providers');
|
||||
this.showAuthPrompt = false;
|
||||
} catch(e) {
|
||||
if (e.message && e.message.indexOf('401') >= 0) {
|
||||
this.showAuthPrompt = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
submitApiKey(key) {
|
||||
if (!key || !key.trim()) return;
|
||||
OpenFangAPI.setAuthToken(key.trim());
|
||||
localStorage.setItem('openfang-api-key', key.trim());
|
||||
this.showAuthPrompt = false;
|
||||
this.refreshAgents();
|
||||
},
|
||||
|
||||
clearApiKey() {
|
||||
OpenFangAPI.setAuthToken('');
|
||||
localStorage.removeItem('openfang-api-key');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -237,6 +266,7 @@ function app() {
|
||||
// Initial data load
|
||||
this.pollStatus();
|
||||
Alpine.store('app').checkOnboarding();
|
||||
Alpine.store('app').checkAuth();
|
||||
setInterval(function() { self.pollStatus(); }, 5000);
|
||||
},
|
||||
|
||||
|
||||
@@ -27,5 +27,10 @@ sha2 = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
|
||||
lettre = { workspace = true }
|
||||
imap = { workspace = true }
|
||||
native-tls = { workspace = true }
|
||||
mailparse = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = { workspace = true }
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
//! Email channel adapter (IMAP + SMTP).
|
||||
//!
|
||||
//! Polls IMAP for new emails and sends responses via SMTP.
|
||||
//! Polls IMAP for new emails and sends responses via SMTP using `lettre`.
|
||||
//! Uses the subject line for agent routing (e.g., "\[coder\] Fix this bug").
|
||||
|
||||
use crate::types::{ChannelAdapter, ChannelContent, ChannelMessage, ChannelType, ChannelUser};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use dashmap::DashMap;
|
||||
use futures::Stream;
|
||||
use lettre::message::Mailbox;
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::AsyncSmtpTransport;
|
||||
use lettre::AsyncTransport;
|
||||
use lettre::Tokio1Executor;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tracing::{debug, info};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// Reply context for email threading (In-Reply-To / Subject continuity).
|
||||
#[derive(Debug, Clone)]
|
||||
struct ReplyCtx {
|
||||
subject: String,
|
||||
message_id: String,
|
||||
}
|
||||
|
||||
/// Email channel adapter using IMAP for receiving and SMTP for sending.
|
||||
pub struct EmailAdapter {
|
||||
/// IMAP server host.
|
||||
@@ -21,7 +35,7 @@ pub struct EmailAdapter {
|
||||
imap_port: u16,
|
||||
/// SMTP server host.
|
||||
smtp_host: String,
|
||||
/// SMTP port (587 for STARTTLS).
|
||||
/// SMTP port (587 for STARTTLS, 465 for implicit TLS).
|
||||
smtp_port: u16,
|
||||
/// Email address (used for both IMAP and SMTP).
|
||||
username: String,
|
||||
@@ -36,6 +50,8 @@ pub struct EmailAdapter {
|
||||
/// Shutdown signal.
|
||||
shutdown_tx: Arc<watch::Sender<bool>>,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
/// Tracks reply context per sender for email threading.
|
||||
reply_ctx: Arc<DashMap<String, ReplyCtx>>,
|
||||
}
|
||||
|
||||
impl EmailAdapter {
|
||||
@@ -69,16 +85,17 @@ impl EmailAdapter {
|
||||
allowed_senders,
|
||||
shutdown_tx: Arc::new(shutdown_tx),
|
||||
shutdown_rx,
|
||||
reply_ctx: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a sender is in the allowlist (empty = allow all). Used in tests.
|
||||
#[allow(dead_code)]
|
||||
fn is_allowed_sender(&self, sender: &str) -> bool {
|
||||
self.allowed_senders.is_empty() || self.allowed_senders.iter().any(|s| sender.contains(s))
|
||||
}
|
||||
|
||||
/// Extract agent name from subject line brackets, e.g., "[coder] Fix the bug" -> Some("coder")
|
||||
#[allow(dead_code)]
|
||||
fn extract_agent_from_subject(subject: &str) -> Option<String> {
|
||||
let subject = subject.trim();
|
||||
if subject.starts_with('[') {
|
||||
@@ -93,7 +110,6 @@ impl EmailAdapter {
|
||||
}
|
||||
|
||||
/// Strip the agent tag from a subject line.
|
||||
#[allow(dead_code)]
|
||||
fn strip_agent_tag(subject: &str) -> String {
|
||||
let subject = subject.trim();
|
||||
if subject.starts_with('[') {
|
||||
@@ -103,6 +119,162 @@ impl EmailAdapter {
|
||||
}
|
||||
subject.to_string()
|
||||
}
|
||||
|
||||
/// Build an async SMTP transport for sending emails.
|
||||
async fn build_smtp_transport(
|
||||
&self,
|
||||
) -> Result<AsyncSmtpTransport<Tokio1Executor>, Box<dyn std::error::Error>> {
|
||||
let creds =
|
||||
Credentials::new(self.username.clone(), self.password.as_str().to_string());
|
||||
|
||||
let transport = if self.smtp_port == 465 {
|
||||
// Implicit TLS (port 465)
|
||||
AsyncSmtpTransport::<Tokio1Executor>::relay(&self.smtp_host)?
|
||||
.port(self.smtp_port)
|
||||
.credentials(creds)
|
||||
.build()
|
||||
} else {
|
||||
// STARTTLS (port 587 or other)
|
||||
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&self.smtp_host)?
|
||||
.port(self.smtp_port)
|
||||
.credentials(creds)
|
||||
.build()
|
||||
};
|
||||
|
||||
Ok(transport)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract `user@domain` from a potentially formatted email string like `"Name <user@domain>"`.
|
||||
fn extract_email_addr(raw: &str) -> String {
|
||||
let raw = raw.trim();
|
||||
if let Some(start) = raw.find('<') {
|
||||
if let Some(end) = raw.find('>') {
|
||||
if end > start {
|
||||
return raw[start + 1..end].trim().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
raw.to_string()
|
||||
}
|
||||
|
||||
/// Get a specific header value from a parsed email.
|
||||
fn get_header(parsed: &mailparse::ParsedMail<'_>, name: &str) -> Option<String> {
|
||||
parsed
|
||||
.headers
|
||||
.iter()
|
||||
.find(|h| h.get_key().eq_ignore_ascii_case(name))
|
||||
.map(|h| h.get_value())
|
||||
}
|
||||
|
||||
/// Extract the text/plain body from a parsed email (handles multipart).
|
||||
fn extract_text_body(parsed: &mailparse::ParsedMail<'_>) -> String {
|
||||
if parsed.subparts.is_empty() {
|
||||
return parsed.get_body().unwrap_or_default();
|
||||
}
|
||||
// Walk subparts looking for text/plain
|
||||
for part in &parsed.subparts {
|
||||
let ct = part.ctype.mimetype.to_lowercase();
|
||||
if ct == "text/plain" {
|
||||
return part.get_body().unwrap_or_default();
|
||||
}
|
||||
}
|
||||
// Fallback: first subpart body
|
||||
parsed
|
||||
.subparts
|
||||
.first()
|
||||
.and_then(|p| p.get_body().ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Fetch unseen emails from IMAP using blocking I/O.
|
||||
/// Returns a Vec of (from_addr, subject, message_id, body).
|
||||
fn fetch_unseen_emails(
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: &str,
|
||||
password: &str,
|
||||
folders: &[String],
|
||||
) -> Result<Vec<(String, String, String, String)>, String> {
|
||||
let tls = native_tls::TlsConnector::builder()
|
||||
.build()
|
||||
.map_err(|e| format!("TLS connector error: {e}"))?;
|
||||
|
||||
let client = imap::connect((host, port), host, &tls)
|
||||
.map_err(|e| format!("IMAP connect failed: {e}"))?;
|
||||
|
||||
let mut session = client
|
||||
.login(username, password)
|
||||
.map_err(|(e, _)| format!("IMAP login failed: {e}"))?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
for folder in folders {
|
||||
if let Err(e) = session.select(folder) {
|
||||
warn!(folder, error = %e, "IMAP SELECT failed, skipping folder");
|
||||
continue;
|
||||
}
|
||||
|
||||
let uids = match session.uid_search("UNSEEN") {
|
||||
Ok(uids) => uids,
|
||||
Err(e) => {
|
||||
warn!(folder, error = %e, "IMAP SEARCH UNSEEN failed");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if uids.is_empty() {
|
||||
debug!(folder, "No unseen emails");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch in batches of up to 50 to avoid huge responses
|
||||
let uid_list: Vec<u32> = uids.into_iter().take(50).collect();
|
||||
let uid_set: String = uid_list
|
||||
.iter()
|
||||
.map(|u| u.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
|
||||
let fetches = match session.uid_fetch(&uid_set, "RFC822") {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
warn!(folder, error = %e, "IMAP FETCH failed");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for fetch in fetches.iter() {
|
||||
let body_bytes = match fetch.body() {
|
||||
Some(b) => b,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let parsed = match mailparse::parse_mail(body_bytes) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "Failed to parse email");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let from = get_header(&parsed, "From").unwrap_or_default();
|
||||
let subject = get_header(&parsed, "Subject").unwrap_or_default();
|
||||
let message_id = get_header(&parsed, "Message-ID").unwrap_or_default();
|
||||
let text_body = extract_text_body(&parsed);
|
||||
|
||||
let from_addr = extract_email_addr(&from);
|
||||
results.push((from_addr, subject, message_id, text_body));
|
||||
}
|
||||
|
||||
// Mark fetched messages as Seen
|
||||
if let Err(e) = session.uid_store(&uid_set, "+FLAGS (\\Seen)") {
|
||||
warn!(error = %e, "Failed to mark emails as Seen");
|
||||
}
|
||||
}
|
||||
|
||||
let _ = session.logout();
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -119,25 +291,23 @@ impl ChannelAdapter for EmailAdapter {
|
||||
&self,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>
|
||||
{
|
||||
let (_tx, rx) = mpsc::channel::<ChannelMessage>(256);
|
||||
let (tx, rx) = mpsc::channel::<ChannelMessage>(256);
|
||||
let poll_interval = self.poll_interval;
|
||||
let _allowed_senders = self.allowed_senders.clone();
|
||||
let imap_host = self.imap_host.clone();
|
||||
let imap_port = self.imap_port;
|
||||
let _username = self.username.clone();
|
||||
let _password = self.password.clone();
|
||||
let _folders = self.folders.clone();
|
||||
let username = self.username.clone();
|
||||
let password = self.password.clone();
|
||||
let folders = self.folders.clone();
|
||||
let allowed_senders = self.allowed_senders.clone();
|
||||
let mut shutdown_rx = self.shutdown_rx.clone();
|
||||
let reply_ctx = self.reply_ctx.clone();
|
||||
|
||||
info!(
|
||||
"Starting email adapter (IMAP: {}:{}, polling every {:?})",
|
||||
imap_host, imap_port, poll_interval
|
||||
"Starting email adapter (IMAP: {}:{}, SMTP: {}:{}, polling every {:?})",
|
||||
imap_host, imap_port, self.smtp_host, self.smtp_port, poll_interval
|
||||
);
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Email polling is blocking I/O, so we'll use spawn_blocking
|
||||
// For now, implement as a polling loop with placeholder
|
||||
// Full IMAP implementation requires the `imap` crate
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = shutdown_rx.changed() => {
|
||||
@@ -147,14 +317,83 @@ impl ChannelAdapter for EmailAdapter {
|
||||
_ = tokio::time::sleep(poll_interval) => {}
|
||||
}
|
||||
|
||||
// Placeholder: In a full implementation, this would:
|
||||
// 1. Connect to IMAP server via TLS
|
||||
// 2. Select each folder
|
||||
// 3. Search for UNSEEN messages
|
||||
// 4. Fetch and parse each message (From, Subject, Body)
|
||||
// 5. Convert to ChannelMessage
|
||||
// 6. Mark as seen
|
||||
debug!("Email poll cycle (IMAP {}:{})", imap_host, imap_port);
|
||||
// IMAP operations are blocking I/O — run in spawn_blocking
|
||||
let host = imap_host.clone();
|
||||
let port = imap_port;
|
||||
let user = username.clone();
|
||||
let pass = password.clone();
|
||||
let fldrs = folders.clone();
|
||||
|
||||
let emails = tokio::task::spawn_blocking(move || {
|
||||
fetch_unseen_emails(&host, port, &user, pass.as_str(), &fldrs)
|
||||
})
|
||||
.await;
|
||||
|
||||
let emails = match emails {
|
||||
Ok(Ok(emails)) => emails,
|
||||
Ok(Err(e)) => {
|
||||
error!("IMAP poll error: {e}");
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("IMAP spawn_blocking panic: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for (from_addr, subject, message_id, body) in emails {
|
||||
// Check allowed senders
|
||||
if !allowed_senders.is_empty()
|
||||
&& !allowed_senders.iter().any(|s| from_addr.contains(s))
|
||||
{
|
||||
debug!(from = %from_addr, "Email from non-allowed sender, skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Store reply context for threading
|
||||
if !message_id.is_empty() {
|
||||
reply_ctx.insert(
|
||||
from_addr.clone(),
|
||||
ReplyCtx {
|
||||
subject: subject.clone(),
|
||||
message_id: message_id.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Extract target agent from subject brackets (stored in metadata for router)
|
||||
let _target_agent =
|
||||
EmailAdapter::extract_agent_from_subject(&subject);
|
||||
let clean_subject = EmailAdapter::strip_agent_tag(&subject);
|
||||
|
||||
// Build the message body: prepend subject context
|
||||
let text = if clean_subject.is_empty() {
|
||||
body.trim().to_string()
|
||||
} else {
|
||||
format!("Subject: {clean_subject}\n\n{}", body.trim())
|
||||
};
|
||||
|
||||
let msg = ChannelMessage {
|
||||
channel: ChannelType::Email,
|
||||
platform_message_id: message_id.clone(),
|
||||
sender: ChannelUser {
|
||||
platform_id: from_addr.clone(),
|
||||
display_name: from_addr.clone(),
|
||||
openfang_user: None,
|
||||
},
|
||||
content: ChannelContent::Text(text),
|
||||
target_agent: None, // Routing handled by bridge AgentRouter
|
||||
timestamp: Utc::now(),
|
||||
is_group: false,
|
||||
thread_id: None,
|
||||
metadata: std::collections::HashMap::new(),
|
||||
};
|
||||
|
||||
if tx.send(msg).await.is_err() {
|
||||
info!("Email channel receiver dropped, stopping poll");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -168,22 +407,71 @@ impl ChannelAdapter for EmailAdapter {
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match content {
|
||||
ChannelContent::Text(text) => {
|
||||
// Placeholder: In a full implementation, this would:
|
||||
// 1. Build email (From, To, Subject, Body) using lettre
|
||||
// 2. Connect to SMTP server via STARTTLS
|
||||
// 3. Send the email
|
||||
// Parse recipient address
|
||||
let to_addr = extract_email_addr(&user.platform_id);
|
||||
let to_mailbox: Mailbox = to_addr
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid recipient email '{}': {}", to_addr, e))?;
|
||||
|
||||
let from_mailbox: Mailbox = self
|
||||
.username
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid sender email '{}': {}", self.username, e))?;
|
||||
|
||||
// Extract subject from text body convention: "Subject: ...\n\n..."
|
||||
let (subject, body) = if text.starts_with("Subject: ") {
|
||||
if let Some(pos) = text.find("\n\n") {
|
||||
let subj = text[9..pos].trim().to_string();
|
||||
let body = text[pos + 2..].to_string();
|
||||
(subj, body)
|
||||
} else {
|
||||
("OpenFang Reply".to_string(), text)
|
||||
}
|
||||
} else {
|
||||
// Check reply context for subject continuity
|
||||
let subj = self
|
||||
.reply_ctx
|
||||
.get(&to_addr)
|
||||
.map(|ctx| format!("Re: {}", ctx.subject))
|
||||
.unwrap_or_else(|| "OpenFang Reply".to_string());
|
||||
(subj, text)
|
||||
};
|
||||
|
||||
// Build email message
|
||||
let mut builder = lettre::Message::builder()
|
||||
.from(from_mailbox)
|
||||
.to(to_mailbox)
|
||||
.subject(&subject);
|
||||
|
||||
// Add In-Reply-To header for threading
|
||||
if let Some(ctx) = self.reply_ctx.get(&to_addr) {
|
||||
if !ctx.message_id.is_empty() {
|
||||
builder = builder.in_reply_to(ctx.message_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let email = builder
|
||||
.body(body)
|
||||
.map_err(|e| format!("Failed to build email: {e}"))?;
|
||||
|
||||
// Send via SMTP
|
||||
let transport = self.build_smtp_transport().await?;
|
||||
transport
|
||||
.send(email)
|
||||
.await
|
||||
.map_err(|e| format!("SMTP send failed: {e}"))?;
|
||||
|
||||
info!(
|
||||
"Would send email to {}: {} chars",
|
||||
user.platform_id,
|
||||
text.len()
|
||||
);
|
||||
debug!(
|
||||
"SMTP: {}:{} -> {}",
|
||||
self.smtp_host, self.smtp_port, user.platform_id
|
||||
to = %to_addr,
|
||||
subject = %subject,
|
||||
"Email sent successfully via SMTP"
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
info!("Unsupported email content type for {}", user.platform_id);
|
||||
warn!(
|
||||
"Unsupported email content type for {}, only text is supported",
|
||||
user.platform_id
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -274,4 +562,40 @@ mod tests {
|
||||
);
|
||||
assert_eq!(EmailAdapter::strip_agent_tag("No brackets"), "No brackets");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_email_addr() {
|
||||
assert_eq!(
|
||||
extract_email_addr("John Doe <john@example.com>"),
|
||||
"john@example.com"
|
||||
);
|
||||
assert_eq!(extract_email_addr("user@example.com"), "user@example.com");
|
||||
assert_eq!(extract_email_addr("<user@test.com>"), "user@test.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subject_extraction_from_body() {
|
||||
let text = "Subject: Test Subject\n\nThis is the body.";
|
||||
assert!(text.starts_with("Subject: "));
|
||||
let pos = text.find("\n\n").unwrap();
|
||||
let subject = &text[9..pos];
|
||||
let body = &text[pos + 2..];
|
||||
assert_eq!(subject, "Test Subject");
|
||||
assert_eq!(body, "This is the body.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reply_ctx_threading() {
|
||||
let ctx_map: DashMap<String, ReplyCtx> = DashMap::new();
|
||||
ctx_map.insert(
|
||||
"user@test.com".to_string(),
|
||||
ReplyCtx {
|
||||
subject: "Original Subject".to_string(),
|
||||
message_id: "<msg-123@test.com>".to_string(),
|
||||
},
|
||||
);
|
||||
let ctx = ctx_map.get("user@test.com").unwrap();
|
||||
assert_eq!(ctx.subject, "Original Subject");
|
||||
assert_eq!(ctx.message_id, "<msg-123@test.com>");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,8 @@ pub struct OpenFangKernel {
|
||||
pub booted_at: std::time::Instant,
|
||||
/// WhatsApp Web gateway child process PID (for shutdown cleanup).
|
||||
pub whatsapp_gateway_pid: Arc<std::sync::Mutex<Option<u32>>>,
|
||||
/// Channel adapters registered at bridge startup (for proactive `channel_send` tool).
|
||||
pub channel_adapters: dashmap::DashMap<String, Arc<dyn openfang_channels::types::ChannelAdapter>>,
|
||||
/// Weak self-reference for trigger dispatch (set after Arc wrapping).
|
||||
self_handle: OnceLock<Weak<OpenFangKernel>>,
|
||||
}
|
||||
@@ -874,6 +876,7 @@ impl OpenFangKernel {
|
||||
peer_node: None,
|
||||
booted_at: std::time::Instant::now(),
|
||||
whatsapp_gateway_pid: Arc::new(std::sync::Mutex::new(None)),
|
||||
channel_adapters: dashmap::DashMap::new(),
|
||||
self_handle: OnceLock::new(),
|
||||
};
|
||||
|
||||
@@ -4712,6 +4715,42 @@ impl KernelHandle for OpenFangKernel {
|
||||
.map(|(url, _)| url.clone())
|
||||
}
|
||||
|
||||
async fn send_channel_message(
|
||||
&self,
|
||||
channel: &str,
|
||||
recipient: &str,
|
||||
message: &str,
|
||||
) -> Result<String, String> {
|
||||
let adapter = self
|
||||
.channel_adapters
|
||||
.get(channel)
|
||||
.ok_or_else(|| {
|
||||
let available: Vec<String> = self
|
||||
.channel_adapters
|
||||
.iter()
|
||||
.map(|e| e.key().clone())
|
||||
.collect();
|
||||
format!(
|
||||
"Channel '{}' not found. Available channels: {:?}",
|
||||
channel, available
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
|
||||
let user = openfang_channels::types::ChannelUser {
|
||||
platform_id: recipient.to_string(),
|
||||
display_name: recipient.to_string(),
|
||||
openfang_user: None,
|
||||
};
|
||||
|
||||
adapter
|
||||
.send(&user, openfang_channels::types::ChannelContent::Text(message.to_string()))
|
||||
.await
|
||||
.map_err(|e| format!("Channel send failed: {e}"))?;
|
||||
|
||||
Ok(format!("Message sent to {} via {}", recipient, channel))
|
||||
}
|
||||
|
||||
async fn spawn_agent_checked(
|
||||
&self,
|
||||
manifest_toml: &str,
|
||||
|
||||
@@ -172,6 +172,18 @@ pub trait KernelHandle: Send + Sync {
|
||||
None
|
||||
}
|
||||
|
||||
/// Send a message to a user on a named channel adapter (e.g., "email", "telegram").
|
||||
/// Returns a confirmation string on success.
|
||||
async fn send_channel_message(
|
||||
&self,
|
||||
channel: &str,
|
||||
recipient: &str,
|
||||
message: &str,
|
||||
) -> Result<String, String> {
|
||||
let _ = (channel, recipient, message);
|
||||
Err("Channel send not available".to_string())
|
||||
}
|
||||
|
||||
/// Spawn an agent with capability inheritance enforcement.
|
||||
/// `parent_caps` are the parent's granted capabilities. The kernel MUST verify
|
||||
/// that every capability in the child manifest is covered by `parent_caps`.
|
||||
|
||||
@@ -293,6 +293,9 @@ pub async fn execute_tool(
|
||||
"cron_list" => tool_cron_list(kernel, caller_agent_id).await,
|
||||
"cron_cancel" => tool_cron_cancel(input, kernel).await,
|
||||
|
||||
// Channel send tool (proactive outbound messaging)
|
||||
"channel_send" => tool_channel_send(input, kernel).await,
|
||||
|
||||
// Persistent process tools
|
||||
"process_start" => tool_process_start(input, process_manager, caller_agent_id).await,
|
||||
"process_poll" => tool_process_poll(input, process_manager).await,
|
||||
@@ -909,6 +912,21 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> {
|
||||
"required": ["job_id"]
|
||||
}),
|
||||
},
|
||||
// --- Channel send tool (proactive outbound messaging) ---
|
||||
ToolDefinition {
|
||||
name: "channel_send".to_string(),
|
||||
description: "Send a message to a user on a configured channel (email, telegram, slack, etc). For email: recipient is the email address; optionally prefix the message with 'Subject: Your Subject\\n\\n' to set the email subject.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel": { "type": "string", "description": "Channel adapter name (e.g., 'email', 'telegram', 'slack', 'discord')" },
|
||||
"recipient": { "type": "string", "description": "Platform-specific recipient identifier (email address, user ID, etc.)" },
|
||||
"subject": { "type": "string", "description": "Optional subject line (used for email; ignored for other channels)" },
|
||||
"message": { "type": "string", "description": "The message body to send" }
|
||||
},
|
||||
"required": ["channel", "recipient", "message"]
|
||||
}),
|
||||
},
|
||||
// --- Hand tools (curated autonomous capability packages) ---
|
||||
ToolDefinition {
|
||||
name: "hand_list".to_string(),
|
||||
@@ -2001,6 +2019,60 @@ async fn tool_cron_cancel(
|
||||
Ok(format!("Cron job '{job_id}' cancelled."))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Channel send tool (proactive outbound messaging via configured adapters)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn tool_channel_send(
|
||||
input: &serde_json::Value,
|
||||
kernel: Option<&Arc<dyn KernelHandle>>,
|
||||
) -> Result<String, String> {
|
||||
let kh = require_kernel(kernel)?;
|
||||
|
||||
let channel = input["channel"]
|
||||
.as_str()
|
||||
.ok_or("Missing 'channel' parameter")?
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
let recipient = input["recipient"]
|
||||
.as_str()
|
||||
.ok_or("Missing 'recipient' parameter")?
|
||||
.trim();
|
||||
let message = input["message"]
|
||||
.as_str()
|
||||
.ok_or("Missing 'message' parameter")?;
|
||||
|
||||
if recipient.is_empty() {
|
||||
return Err("Recipient cannot be empty".to_string());
|
||||
}
|
||||
if message.is_empty() {
|
||||
return Err("Message cannot be empty".to_string());
|
||||
}
|
||||
|
||||
// For email channels, validate email format and prepend subject
|
||||
let final_message = if channel == "email" {
|
||||
// Basic email format validation
|
||||
if !recipient.contains('@') || !recipient.contains('.') {
|
||||
return Err(format!("Invalid email address: '{recipient}'"));
|
||||
}
|
||||
// Prepend subject if provided
|
||||
if let Some(subject) = input["subject"].as_str() {
|
||||
if !subject.is_empty() {
|
||||
format!("Subject: {subject}\n\n{message}")
|
||||
} else {
|
||||
message.to_string()
|
||||
}
|
||||
} else {
|
||||
message.to_string()
|
||||
}
|
||||
} else {
|
||||
message.to_string()
|
||||
};
|
||||
|
||||
kh.send_channel_message(&channel, recipient, &final_message)
|
||||
.await
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hand tools (delegated to kernel via KernelHandle trait)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2953,6 +3025,8 @@ mod tests {
|
||||
assert!(names.contains(&"cron_create"));
|
||||
assert!(names.contains(&"cron_list"));
|
||||
assert!(names.contains(&"cron_cancel"));
|
||||
// 1 channel send tool
|
||||
assert!(names.contains(&"channel_send"));
|
||||
// 4 hand tools
|
||||
assert!(names.contains(&"hand_list"));
|
||||
assert!(names.contains(&"hand_activate"));
|
||||
|
||||
Reference in New Issue
Block a user