Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
940 changes: 923 additions & 17 deletions Cargo.lock

Large diffs are not rendered by default.

20 changes: 18 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ default = []
# `tokio/net` are now unconditional dependencies (the ported channel providers
# use them directly), so this feature no longer needs to pull them in.
relay-websocket = []
# WhatsApp Web (multi-device) provider — pulls the native whatsapp-rust stack.
# Off by default; the OpenHuman `whatsapp-web` feature forwards to this one.
whatsapp-web = [
"dep:whatsapp-rust",
"dep:whatsapp-rust-tokio-transport",
"dep:whatsapp-rust-ureq-http-client",
"dep:wacore",
"dep:serde-big-array",
]

[dependencies]
anyhow = "1"
Expand All @@ -30,7 +39,7 @@ hmac = "0.12"
parking_lot = "0.12"
prost = "0.14"
rand = "0.10"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "stream"] }
rusqlite = { version = "0.40", features = ["bundled"] }
rustls = { version = "0.23", features = ["ring"] }
rustls-pki-types = "1.14.0"
Expand All @@ -40,7 +49,7 @@ serde_json = "1"
sha1 = "0.10"
sha2 = "0.10"
thiserror = "2"
tokio = { version = "1", default-features = false, features = ["io-util", "macros", "net", "process", "rt", "sync", "time"] }
tokio = { version = "1", default-features = false, features = ["fs", "io-util", "macros", "net", "process", "rt", "sync", "time"] }
tokio-rustls = "0.26.4"
tokio-tungstenite = { version = "0.29", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] }
tracing = "0.1"
Expand All @@ -57,6 +66,13 @@ mail-parser = "0.11.2"
# Lark/Feishu webhook (HTTP receive mode).
axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio", "query", "ws", "macros"] }

# WhatsApp Web provider (behind the `whatsapp-web` feature).
serde-big-array = { version = "0.5", optional = true }
whatsapp-rust = { version = "0.5", optional = true, default-features = false, features = ["tokio-runtime"] }
whatsapp-rust-tokio-transport = { version = "0.5", optional = true, default-features = false }
whatsapp-rust-ureq-http-client = { version = "0.5", optional = true }
wacore = { version = "0.5", optional = true, default-features = false }

[dev-dependencies]
tempfile = "3"
toml = "0.9"
Expand Down
16 changes: 12 additions & 4 deletions src/host/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ mod services;
pub use dispatch::{DispatchOptions, DispatchRequest, TurnDispatcher, TurnHandle};
pub use noop::{ChannelHostBuilder, NoopHost};
pub use services::{
ApprovalAsk, ApprovalDecision, ApprovalGate, ConversationMessage, ConversationStore, EventSink,
LifecycleRegistry, ReactionDecision, ReactionGate, RunEventAppend, RunLedger, RunTelemetry,
RunUpsert, ShutdownHook, SpeechRequest, SpeechResult, SpeechSynthesizer, Transcriber,
TranscriptionRequest, TranscriptionResult,
AllowlistStore, ApprovalAsk, ApprovalDecision, ApprovalGate, ConversationMessage,
ConversationStore, EventSink, LifecycleRegistry, ReactionDecision, ReactionGate, ReactionQuery,
RunEventAppend, RunLedger, RunTelemetry, RunUpsert, ShutdownHook, SpeechRequest, SpeechResult,
SpeechSynthesizer, Transcriber, TranscriptionRequest, TranscriptionResult,
};

use crate::config::ChannelsConfig;
Expand All @@ -72,6 +72,7 @@ pub struct HostCapabilities {
pub event_sink: bool,
pub lifecycle: bool,
pub run_ledger: bool,
pub allowlist_store: bool,
}

impl HostCapabilities {
Expand All @@ -87,6 +88,7 @@ impl HostCapabilities {
event_sink: false,
lifecycle: false,
run_ledger: false,
allowlist_store: false,
};

/// Derive the descriptor from a live host by probing each accessor.
Expand All @@ -102,6 +104,7 @@ impl HostCapabilities {
event_sink: host.events().is_some(),
lifecycle: host.lifecycle().is_some(),
run_ledger: host.ledger().is_some(),
allowlist_store: host.allowlist().is_some(),
}
}

Expand Down Expand Up @@ -159,6 +162,10 @@ pub trait ChannelHost: Send + Sync {
fn ledger(&self) -> Option<Arc<dyn RunLedger>> {
None
}
/// Persisted allowlist store (promote paired/authorized identities).
fn allowlist(&self) -> Option<Arc<dyn AllowlistStore>> {
None
}

/// Snapshot of available capabilities. Defaults to probing the accessors;
/// hosts may override with a cached descriptor.
Expand All @@ -174,6 +181,7 @@ pub trait ChannelHost: Send + Sync {
event_sink: self.events().is_some(),
lifecycle: self.lifecycle().is_some(),
run_ledger: self.ledger().is_some(),
allowlist_store: self.allowlist().is_some(),
}
}
}
Expand Down
14 changes: 12 additions & 2 deletions src/host/noop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
//! (e.g. dispatcher + STT this week, ledger next) without a bespoke host type.

use super::{
ApprovalGate, ChannelHost, ConversationStore, EventSink, LifecycleRegistry, Memory,
ReactionGate, RunLedger, SpeechSynthesizer, Transcriber, TurnDispatcher,
AllowlistStore, ApprovalGate, ChannelHost, ConversationStore, EventSink, LifecycleRegistry,
Memory, ReactionGate, RunLedger, SpeechSynthesizer, Transcriber, TurnDispatcher,
};
use std::sync::Arc;

Expand Down Expand Up @@ -45,6 +45,7 @@ pub struct ChannelHostBuilder {
events: Option<Arc<dyn EventSink>>,
lifecycle: Option<Arc<dyn LifecycleRegistry>>,
ledger: Option<Arc<dyn RunLedger>>,
allowlist: Option<Arc<dyn AllowlistStore>>,
}

impl ChannelHostBuilder {
Expand Down Expand Up @@ -92,6 +93,10 @@ impl ChannelHostBuilder {
self.ledger = Some(value);
self
}
pub fn allowlist(mut self, value: Arc<dyn AllowlistStore>) -> Self {
self.allowlist = Some(value);
self
}

/// Finalize into a shareable host.
pub fn build(self) -> Arc<dyn ChannelHost> {
Expand All @@ -106,6 +111,7 @@ impl ChannelHostBuilder {
events: self.events,
lifecycle: self.lifecycle,
ledger: self.ledger,
allowlist: self.allowlist,
})
}
}
Expand All @@ -122,6 +128,7 @@ struct CompositeHost {
events: Option<Arc<dyn EventSink>>,
lifecycle: Option<Arc<dyn LifecycleRegistry>>,
ledger: Option<Arc<dyn RunLedger>>,
allowlist: Option<Arc<dyn AllowlistStore>>,
}

impl ChannelHost for CompositeHost {
Expand Down Expand Up @@ -155,4 +162,7 @@ impl ChannelHost for CompositeHost {
fn ledger(&self) -> Option<Arc<dyn RunLedger>> {
self.ledger.clone()
}
fn allowlist(&self) -> Option<Arc<dyn AllowlistStore>> {
self.allowlist.clone()
}
}
45 changes: 39 additions & 6 deletions src/host/services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,14 @@ pub enum ApprovalDecision {
#[async_trait]
pub trait ApprovalGate: Send + Sync {
/// Raise an approval and await its resolution (blocks the turn).
async fn request(&self, ask: ApprovalAsk) -> anyhow::Result<ApprovalDecision>;
///
/// Defaults to an "unsupported" error: in OpenHuman today approvals are
/// *raised* by the tool gate (host-internal) and channels only observe the
/// surface + [`ApprovalGate::parse_reply`] inbound replies. Hosts that can
/// mediate interactive requests override this.
async fn request(&self, _ask: ApprovalAsk) -> anyhow::Result<ApprovalDecision> {
anyhow::bail!("interactive approval requests are not supported by this host")
}

/// Interpret a free-text inbound reply as an approval decision
/// (`"yes"`/`"no"`/`"1"`), or `None` if it isn't one. Default: no parse.
Expand All @@ -128,15 +135,21 @@ pub struct ReactionQuery {
pub channel_type: String,
}

/// Whether the assistant should react, with an optional rationale.
/// Whether the assistant should react, plus the emoji to use for an emoji
/// reaction (`presentation`/`telegram` ACK reactions) and an optional rationale.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ReactionDecision {
pub should_react: bool,
/// Emoji to react with when `should_react` is true (`None` = no emoji,
/// e.g. a pure suppress/allow gate). Mirrors OpenHuman's `ReactionDecision`.
pub emoji: Option<String>,
/// Optional human-readable rationale for logs/telemetry.
pub reason: Option<String>,
}

/// Inference-driven "should I respond?" gate for busy group channels.
/// Needed by: **web, telegram, presentation** (group-chat suppression).
/// Inference-driven reaction gate: whether to respond/react and with what
/// emoji. Backs both group-chat suppression and emoji-ACK reactions.
/// Needed by: **web, telegram, presentation**.
#[async_trait]
pub trait ReactionGate: Send + Sync {
async fn should_react(&self, query: ReactionQuery) -> anyhow::Result<ReactionDecision>;
Expand Down Expand Up @@ -195,8 +208,13 @@ pub trait EventSink: Send + Sync {
// Lifecycle registry (graceful shutdown)
// ---------------------------------------------------------------------------

/// A one-shot cleanup callback run at process shutdown.
pub type ShutdownHook = Box<dyn FnOnce() + Send + 'static>;
/// The future a [`ShutdownHook`] returns when invoked.
pub type ShutdownFuture = std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'static>>;

/// A one-shot **async** cleanup callback run at process shutdown. Async so a
/// hook can await real teardown (close a WebSocket, flush a session) — mirrors
/// the host's own async shutdown-hook contract.
pub type ShutdownHook = Box<dyn FnOnce() -> ShutdownFuture + Send + 'static>;

/// Register cleanup that must run when the host shuts down (close sockets,
/// flush sessions). Needed by: **whatsapp_web** (WA session teardown), any
Expand All @@ -206,6 +224,21 @@ pub trait LifecycleRegistry: Send + Sync {
fn register_shutdown(&self, name: &str, hook: ShutdownHook);
}

// ---------------------------------------------------------------------------
// Allowlist store (persisted access control)
// ---------------------------------------------------------------------------

/// Persist a newly-authorized identity into a channel's configured allowlist so
/// it survives restarts. Needed by: **telegram** (first-run bind-code pairing
/// promotes the paired account into `allowed_users`). The host owns the config
/// file; the provider only names the channel + identity.
#[async_trait]
pub trait AllowlistStore: Send + Sync {
/// Add `identity` to `channel`'s persisted allowlist (idempotent). Errors
/// if the channel has no config section to persist into.
async fn persist_allowed_identity(&self, channel: &str, identity: &str) -> anyhow::Result<()>;
}

// ---------------------------------------------------------------------------
// Run ledger (telemetry / observability)
// ---------------------------------------------------------------------------
Expand Down
3 changes: 2 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pub mod providers;
pub mod relay;
pub mod routes;
pub mod runtime;
pub mod security;
pub mod text;
pub mod traits;

Expand All @@ -41,6 +42,6 @@ pub use host::{ChannelHost, ChannelHostBuilder, HostCapabilities, NoopHost, Prov
pub use providers::{
DingTalkChannel, DiscordChannel, EmailChannel, IMessageChannel, IrcChannel, IrcChannelConfig,
LarkChannel, LinqChannel, MattermostChannel, QQChannel, SignalChannel, SlackChannel,
WhatsAppChannel, YuanbaoChannel,
TelegramChannel, WhatsAppChannel, WhatsAppWebChannel, YuanbaoChannel,
};
pub use traits::{Channel, ChannelMessage, ChannelSendExt, SendMessage};
11 changes: 5 additions & 6 deletions src/providers/discord/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -330,12 +330,11 @@ async fn check_channel_permissions_at_base(
let is_everyone = role_id == guild_id; // @everyone role ID == guild ID
let is_member_role = member_role_ids.contains(&role_id);

if is_everyone || is_member_role {
if let Some(perms_str) = role.get("permissions").and_then(|p| p.as_str()) {
if let Ok(perms) = perms_str.parse::<u64>() {
permissions |= perms;
}
}
if (is_everyone || is_member_role)
&& let Some(perms_str) = role.get("permissions").and_then(|p| p.as_str())
&& let Ok(perms) = perms_str.parse::<u64>()
{
permissions |= perms;
}
}

Expand Down
95 changes: 47 additions & 48 deletions src/providers/email_channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,12 @@ impl EmailChannel {
}
for part in parsed.attachments() {
let part: &mail_parser::MessagePart = part;
if let Some(ct) = MimeHeaders::content_type(part) {
if ct.ctype() == "text" {
if let Ok(text) = std::str::from_utf8(part.contents()) {
let name = MimeHeaders::attachment_name(part).unwrap_or("file");
return format!("[Attachment: {}]\n{}", name, text);
}
}
if let Some(ct) = MimeHeaders::content_type(part)
&& ct.ctype() == "text"
&& let Ok(text) = std::str::from_utf8(part.contents())
{
let name = MimeHeaders::attachment_name(part).unwrap_or("file");
return format!("[Attachment: {}]\n{}", name, text);
}
}
"(no readable content)".to_string()
Expand Down Expand Up @@ -182,50 +181,50 @@ impl EmailChannel {

for msg in messages {
let uid = msg.uid.unwrap_or(0);
if let Some(body) = msg.body() {
if let Some(parsed) = MessageParser::default().parse(body) {
let sender = Self::extract_sender(&parsed);
let subject = parsed.subject().unwrap_or("(no subject)").to_string();
let body_text = Self::extract_text(&parsed);
let content = format!("Subject: {}\n\n{}", subject, body_text);
let msg_id = parsed
.message_id()
.map(|s| s.to_string())
.unwrap_or_else(|| format!("gen-{}", Uuid::new_v4()));

#[allow(clippy::cast_sign_loss)]
let ts = parsed
.date()
.map(|d| {
let naive = chrono::NaiveDate::from_ymd_opt(
d.year as i32,
u32::from(d.month),
u32::from(d.day),
if let Some(body) = msg.body()
&& let Some(parsed) = MessageParser::default().parse(body)
{
let sender = Self::extract_sender(&parsed);
let subject = parsed.subject().unwrap_or("(no subject)").to_string();
let body_text = Self::extract_text(&parsed);
let content = format!("Subject: {}\n\n{}", subject, body_text);
let msg_id = parsed
.message_id()
.map(|s| s.to_string())
.unwrap_or_else(|| format!("gen-{}", Uuid::new_v4()));

#[allow(clippy::cast_sign_loss)]
let ts = parsed
.date()
.map(|d| {
let naive = chrono::NaiveDate::from_ymd_opt(
d.year as i32,
u32::from(d.month),
u32::from(d.day),
)
.and_then(|date| {
date.and_hms_opt(
u32::from(d.hour),
u32::from(d.minute),
u32::from(d.second),
)
.and_then(|date| {
date.and_hms_opt(
u32::from(d.hour),
u32::from(d.minute),
u32::from(d.second),
)
});
naive.map_or(0, |n| n.and_utc().timestamp() as u64)
})
.unwrap_or_else(|| {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
});

results.push(ParsedEmail {
_uid: uid,
msg_id,
sender,
content,
timestamp: ts,
naive.map_or(0, |n| n.and_utc().timestamp() as u64)
})
.unwrap_or_else(|| {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
});
}

results.push(ParsedEmail {
_uid: uid,
msg_id,
sender,
content,
timestamp: ts,
});
}
}

Expand Down
Loading