diff --git a/Cargo.lock b/Cargo.lock
index b44a50c403..362c45de22 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4424,7 +4424,6 @@ dependencies = [
"schemars",
"sentry",
"serde",
- "serde-big-array",
"serde_json",
"serde_repr",
"serde_yaml",
@@ -4466,13 +4465,9 @@ dependencies = [
"url",
"urlencoding",
"uuid 1.23.1",
- "wacore",
"wait-timeout",
"walkdir",
"webpki-roots 1.0.7",
- "whatsapp-rust",
- "whatsapp-rust-tokio-transport",
- "whatsapp-rust-ureq-http-client",
"whisper-rs",
"windows-sys 0.61.2",
"wiremock",
@@ -6830,20 +6825,44 @@ name = "tinychannels"
version = "0.1.0"
dependencies = [
"anyhow",
+ "async-imap",
"async-trait",
+ "axum",
"base64 0.22.1",
+ "chrono",
+ "directories",
+ "futures",
"futures-util",
+ "hex",
"hmac 0.12.1",
+ "lettre",
+ "mail-parser",
+ "parking_lot",
+ "prost",
+ "rand 0.10.1",
"reqwest 0.12.28",
+ "rusqlite",
+ "rustls",
+ "rustls-pki-types",
"schemars",
"serde",
+ "serde-big-array",
"serde_json",
+ "sha1",
"sha2 0.10.9",
"thiserror 2.0.18",
"tokio",
+ "tokio-rustls",
"tokio-tungstenite 0.29.0",
"tracing",
+ "url",
+ "urlencoding",
"uuid 1.23.1",
+ "wacore",
+ "webpki-roots 1.0.7",
+ "whatsapp-rust",
+ "whatsapp-rust-tokio-transport",
+ "whatsapp-rust-ureq-http-client",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 70240e8696..c4a095fafe 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -246,16 +246,10 @@ coins-bip39 = "0.8"
curve25519-dalek = { version = "4", default-features = false, features = ["alloc"] }
fantoccini = { version = "0.22.0", optional = true, default-features = false, features = ["rustls-tls"] }
-serde-big-array = { version = "0.5", optional = true }
pdf-extract = "0.10"
-# WhatsApp Web — upstream `whatsapp-rust` 0.5. Its Diesel-backed sqlite-storage
-# feature links sqlite3 separately from rusqlite 0.40, so the TinyAgents 1.5
-# baseline compiles this provider against wacore's in-memory Backend until a
-# rusqlite-backed durable store lands.
-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 }
+# The WhatsApp Web provider (and its `whatsapp-rust` / `wacore` / `serde-big-array`
+# stack) now lives in the tinychannels crate; the `whatsapp-web` feature forwards
+# to `tinychannels/whatsapp-web`.
ppt-rs = "0.2.14"
[target.'cfg(windows)'.dependencies]
@@ -335,7 +329,8 @@ peripheral-rpi = ["dep:rppal"]
browser-native = ["dep:fantoccini"]
fantoccini = ["browser-native"]
landlock = ["sandbox-landlock"]
-whatsapp-web = ["dep:whatsapp-rust", "dep:whatsapp-rust-tokio-transport", "dep:whatsapp-rust-ureq-http-client", "dep:wacore", "serde-big-array"]
+# The WhatsApp Web provider now lives in tinychannels; forward to its feature.
+whatsapp-web = ["tinychannels/whatsapp-web"]
# Exposes the destructive `openhuman.test_reset` RPC. Off by default; the E2E
# build (app/scripts/e2e-build.sh) flips it on. Shipped binaries never have
# this feature so the wipe RPC isn't even registered, let alone reachable.
diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock
index 7422ebe595..7666dacd88 100644
--- a/app/src-tauri/Cargo.lock
+++ b/app/src-tauri/Cargo.lock
@@ -9179,20 +9179,39 @@ name = "tinychannels"
version = "0.1.0"
dependencies = [
"anyhow",
+ "async-imap",
"async-trait",
+ "axum",
"base64 0.22.1",
+ "chrono",
+ "directories 6.0.0",
+ "futures",
"futures-util",
+ "hex",
"hmac 0.12.1",
+ "lettre",
+ "mail-parser",
+ "parking_lot",
+ "prost",
+ "rand 0.10.1",
"reqwest 0.12.28",
+ "rusqlite",
+ "rustls",
+ "rustls-pki-types",
"schemars 1.2.1",
"serde",
"serde_json",
+ "sha1",
"sha2 0.10.9",
"thiserror 2.0.18",
"tokio",
+ "tokio-rustls",
"tokio-tungstenite 0.29.0",
"tracing",
+ "url",
+ "urlencoding",
"uuid 1.23.1",
+ "webpki-roots 1.0.7",
]
[[package]]
diff --git a/src/openhuman/agent/task_dispatcher/executor.rs b/src/openhuman/agent/task_dispatcher/executor.rs
index bc125a1a7b..97bca3d072 100644
--- a/src/openhuman/agent/task_dispatcher/executor.rs
+++ b/src/openhuman/agent/task_dispatcher/executor.rs
@@ -227,7 +227,7 @@ pub(super) async fn run_autonomous(
if let Some(thread_id) = session_thread_id.as_deref() {
match &result {
Ok(response) => {
- crate::openhuman::channels::providers::presentation::deliver_response(
+ crate::openhuman::channels::providers::web::presentation::deliver_response(
"system",
thread_id,
run_id,
diff --git a/src/openhuman/channels/commands.rs b/src/openhuman/channels/commands.rs
index 41edc4ece9..a28dee4250 100644
--- a/src/openhuman/channels/commands.rs
+++ b/src/openhuman/channels/commands.rs
@@ -64,13 +64,14 @@ pub async fn doctor_channels(config: Config) -> Result<()> {
if let Some(ref dc) = config.channels_config.discord {
channels.push((
"Discord",
- Arc::new(DiscordChannel::new(
+ Arc::new(DiscordChannel::with_http_client(
dc.bot_token.clone(),
dc.guild_id.clone(),
dc.channel_id.clone(),
dc.allowed_users.clone(),
dc.listen_to_bots,
dc.mention_only,
+ crate::openhuman::config::build_runtime_proxy_client("channel.discord"),
)),
));
}
@@ -209,10 +210,11 @@ pub async fn doctor_channels(config: Config) -> Result<()> {
if let Some(ref dt) = config.channels_config.dingtalk {
channels.push((
"DingTalk",
- Arc::new(DingTalkChannel::new(
+ Arc::new(DingTalkChannel::with_http_client(
dt.client_id.clone(),
dt.client_secret.clone(),
dt.allowed_users.clone(),
+ crate::openhuman::config::build_runtime_proxy_client("channel.dingtalk"),
)),
));
}
@@ -220,10 +222,11 @@ pub async fn doctor_channels(config: Config) -> Result<()> {
if let Some(ref qq) = config.channels_config.qq {
channels.push((
"QQ",
- Arc::new(QQChannel::new(
+ Arc::new(QQChannel::with_http_client(
qq.app_id.clone(),
qq.app_secret.clone(),
qq.allowed_users.clone(),
+ crate::openhuman::config::build_runtime_proxy_client("channel.qq"),
)),
));
}
diff --git a/src/openhuman/channels/host/adapters.rs b/src/openhuman/channels/host/adapters.rs
new file mode 100644
index 0000000000..bcf1f88e54
--- /dev/null
+++ b/src/openhuman/channels/host/adapters.rs
@@ -0,0 +1,377 @@
+//! OpenHuman-side implementations of the portable `tinychannels::host`
+//! capability traits.
+//!
+//! Each adapter wraps existing OpenHuman internals (voice factory, inference
+//! ops, approval gate, conversation store, shutdown registry, web event bus)
+//! and exposes them through the portable, `Config`-free trait surface a ported
+//! channel provider consumes. See [`super::build_channel_host`].
+
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use parking_lot::Mutex;
+use tinychannels::host::{
+ AllowlistStore, ApprovalDecision, ApprovalGate, ConversationMessage, ConversationStore,
+ EventSink, LifecycleRegistry, ReactionDecision, ReactionGate, ReactionQuery, ShutdownHook,
+ SpeechRequest, SpeechResult, SpeechSynthesizer, Transcriber, TranscriptionRequest,
+ TranscriptionResult,
+};
+
+use crate::openhuman::config::Config;
+
+const LOG_PREFIX: &str = "[channels_host]";
+
+// ---------------------------------------------------------------------------
+// LifecycleRegistry → core::shutdown
+// ---------------------------------------------------------------------------
+
+/// Bridges [`LifecycleRegistry`] onto the process-global async shutdown hook
+/// registry. Providers register teardown that runs once on shutdown.
+pub struct CoreShutdownRegistry;
+
+impl LifecycleRegistry for CoreShutdownRegistry {
+ fn register_shutdown(&self, name: &str, hook: ShutdownHook) {
+ let name = name.to_string();
+ // `core::shutdown::register` takes a re-callable `Fn`, but our hook is a
+ // one-shot `FnOnce`; guard it behind a take-once slot so a (theoretical)
+ // second invocation is a no-op.
+ let slot = Arc::new(Mutex::new(Some(hook)));
+ crate::core::shutdown::register(move || {
+ let slot = Arc::clone(&slot);
+ let name = name.clone();
+ async move {
+ let taken = slot.lock().take();
+ if let Some(hook) = taken {
+ tracing::debug!("{LOG_PREFIX} running shutdown hook: {name}");
+ hook().await;
+ }
+ }
+ });
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Transcriber → voice STT factory
+// ---------------------------------------------------------------------------
+
+/// Speech-to-text backed by the OpenHuman voice provider factory.
+pub struct VoiceTranscriber {
+ pub config: Arc,
+}
+
+#[async_trait]
+impl Transcriber for VoiceTranscriber {
+ fn name(&self) -> &str {
+ "openhuman-voice"
+ }
+
+ async fn transcribe(
+ &self,
+ request: TranscriptionRequest,
+ ) -> anyhow::Result {
+ let provider = crate::openhuman::voice::effective_stt_provider(&self.config);
+ tracing::debug!(
+ "{LOG_PREFIX} transcribe provider={provider} bytes_b64={}",
+ request.audio_base64.len()
+ );
+ // Empty model → factory substitutes DEFAULT_WHISPER_MODEL.
+ let stt = crate::openhuman::voice::create_stt_provider(&provider, "", &self.config)?;
+ let outcome = stt
+ .transcribe(
+ &self.config,
+ &request.audio_base64,
+ request.mime_type.as_deref(),
+ request.file_name.as_deref(),
+ request.language.as_deref(),
+ )
+ .await
+ .map_err(|e| anyhow::anyhow!(e))?;
+ Ok(TranscriptionResult {
+ text: outcome.value.text,
+ language: request.language,
+ duration_secs: None,
+ })
+ }
+}
+
+// ---------------------------------------------------------------------------
+// SpeechSynthesizer → voice reply_speech
+// ---------------------------------------------------------------------------
+
+/// Text-to-speech backed by the hosted reply-speech synthesizer.
+pub struct VoiceSynthesizer {
+ pub config: Arc,
+}
+
+#[async_trait]
+impl SpeechSynthesizer for VoiceSynthesizer {
+ async fn synthesize(&self, request: SpeechRequest) -> anyhow::Result {
+ tracing::debug!(
+ "{LOG_PREFIX} synthesize chars={} voice={:?}",
+ request.text.len(),
+ request.voice
+ );
+ let opts = crate::openhuman::voice::reply_speech::ReplySpeechOptions {
+ voice_id: request.voice,
+ model_id: None,
+ output_format: request.format,
+ voice_settings: None,
+ };
+ let outcome = crate::openhuman::voice::reply_speech::synthesize_reply(
+ &self.config,
+ &request.text,
+ &opts,
+ )
+ .await
+ .map_err(|e| anyhow::anyhow!(e))?;
+ let value = outcome.value;
+ let visemes = serde_json::to_value(&value.visemes).ok();
+ Ok(SpeechResult {
+ audio_base64: value.audio_base64,
+ mime_type: value.audio_mime,
+ visemes,
+ })
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ReactionGate → inference should_react
+// ---------------------------------------------------------------------------
+
+/// Inference-driven reaction gate backed by the local-AI should-react op.
+pub struct InferenceReactionGate {
+ pub config: Arc,
+}
+
+#[async_trait]
+impl ReactionGate for InferenceReactionGate {
+ async fn should_react(&self, query: ReactionQuery) -> anyhow::Result {
+ // Honour the runtime gate: when the local model runtime is disabled we
+ // never react (matches presentation's prior inline guard).
+ if !self.config.local_ai.runtime_enabled {
+ tracing::debug!("{LOG_PREFIX} should_react skipped (local runtime disabled)");
+ return Ok(ReactionDecision::default());
+ }
+ let outcome = crate::openhuman::inference::ops::inference_should_react(
+ &self.config,
+ &query.message,
+ &query.channel_type,
+ )
+ .await
+ .map_err(|e| anyhow::anyhow!(e))?;
+ Ok(ReactionDecision {
+ should_react: outcome.value.should_react,
+ emoji: outcome.value.emoji,
+ reason: None,
+ })
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ApprovalGate → approval reply parsing
+// ---------------------------------------------------------------------------
+
+/// Parses inbound approval replies via the OpenHuman approval gate. Raising
+/// interactive approvals stays host-internal (the tool gate), so only
+/// [`ApprovalGate::parse_reply`] is implemented.
+pub struct CoreApprovalGate;
+
+impl ApprovalGate for CoreApprovalGate {
+ fn parse_reply(&self, message: &str) -> Option {
+ crate::openhuman::approval::parse_approval_reply(message).map(|decision| {
+ use crate::openhuman::approval::ApprovalDecision as Core;
+ match decision {
+ Core::ApproveOnce => ApprovalDecision::Approve,
+ Core::ApproveAlwaysForTool => {
+ ApprovalDecision::Choice("approve_always_for_tool".to_string())
+ }
+ Core::Deny => ApprovalDecision::Deny,
+ }
+ })
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ConversationStore → memory_conversations
+// ---------------------------------------------------------------------------
+
+/// Durable conversation history backed by the OpenHuman conversation store.
+pub struct ConversationHistoryStore {
+ pub workspace_dir: std::path::PathBuf,
+}
+
+#[async_trait]
+impl ConversationStore for ConversationHistoryStore {
+ async fn history(
+ &self,
+ session_key: &str,
+ limit: usize,
+ ) -> anyhow::Result> {
+ let messages = crate::openhuman::memory_conversations::get_messages(
+ self.workspace_dir.clone(),
+ session_key,
+ )
+ .map_err(|e| anyhow::anyhow!(e))?;
+ let start = messages.len().saturating_sub(limit);
+ Ok(messages[start..]
+ .iter()
+ .map(|m| ConversationMessage {
+ role: m.message_type.clone(),
+ content: m.content.clone(),
+ timestamp: None,
+ })
+ .collect())
+ }
+
+ async fn append(&self, session_key: &str, message: ConversationMessage) -> anyhow::Result<()> {
+ let now = chrono::Utc::now().to_rfc3339();
+ // `append_message` requires the thread to exist; create-or-noop first.
+ crate::openhuman::memory_conversations::ensure_thread(
+ self.workspace_dir.clone(),
+ crate::openhuman::memory_conversations::CreateConversationThread {
+ id: session_key.to_string(),
+ title: session_key.to_string(),
+ created_at: now.clone(),
+ parent_thread_id: None,
+ labels: None,
+ personality_id: None,
+ },
+ )
+ .map_err(|e| anyhow::anyhow!(e))?;
+ let stored = crate::openhuman::memory_conversations::ConversationMessage {
+ id: uuid::Uuid::new_v4().to_string(),
+ content: message.content,
+ message_type: message.role.clone(),
+ extra_metadata: serde_json::Value::Null,
+ sender: message.role,
+ created_at: now,
+ };
+ crate::openhuman::memory_conversations::append_message(
+ self.workspace_dir.clone(),
+ session_key,
+ stored,
+ )
+ .map_err(|e| anyhow::anyhow!(e))?;
+ Ok(())
+ }
+}
+
+// ---------------------------------------------------------------------------
+// AllowlistStore → config.toml channel allowlist
+// ---------------------------------------------------------------------------
+
+/// Persists newly-authorized identities into the on-disk channel allowlist,
+/// replicating Telegram's former `persist_allowed_identity` (load
+/// `~/.openhuman/config.toml`, append to the channel's `allowed_users`, save).
+pub struct ConfigAllowlistStore;
+
+#[async_trait]
+impl AllowlistStore for ConfigAllowlistStore {
+ async fn persist_allowed_identity(&self, channel: &str, identity: &str) -> anyhow::Result<()> {
+ use anyhow::Context;
+ let normalized = identity.trim().trim_start_matches('@').to_string();
+ if normalized.is_empty() {
+ anyhow::bail!("cannot persist empty identity");
+ }
+
+ let home = directories::UserDirs::new()
+ .map(|u| u.home_dir().to_path_buf())
+ .context("could not find home directory")?;
+ let openhuman_dir = home.join(".openhuman");
+ let config_path = openhuman_dir.join("config.toml");
+ let contents = tokio::fs::read_to_string(&config_path)
+ .await
+ .with_context(|| format!("failed to read config file: {}", config_path.display()))?;
+ let mut config: Config =
+ toml::from_str(&contents).context("failed to parse config.toml for allowlist")?;
+ config.config_path = config_path;
+ config.workspace_dir = openhuman_dir.join("workspace");
+
+ match channel {
+ "telegram" => {
+ let Some(telegram) = config.channels_config.telegram.as_mut() else {
+ anyhow::bail!("telegram channel config is missing in config.toml");
+ };
+ if !telegram.allowed_users.iter().any(|u| u == &normalized) {
+ telegram.allowed_users.push(normalized);
+ config
+ .save()
+ .await
+ .context("failed to persist allowlist to config.toml")?;
+ }
+ }
+ other => anyhow::bail!("allowlist persist unsupported for channel '{other}'"),
+ }
+ tracing::debug!("{LOG_PREFIX} persisted allowed identity for channel={channel}");
+ Ok(())
+ }
+}
+
+// ---------------------------------------------------------------------------
+// EventSink → routes provider events to the right OpenHuman bus
+// ---------------------------------------------------------------------------
+
+/// Routes provider events by `domain`:
+/// - `"web"` → the web channel's `WebChannelEvent` broadcast bus (payload
+/// must deserialize into a `WebChannelEvent`; presentation builds that shape).
+/// - `"channel"` → the global `DomainEvent` bus (telegram reaction fan-out).
+///
+/// One capability, two backends — providers don't know which bus they hit.
+pub struct OpenHumanEventSink;
+
+fn json_str(payload: &serde_json::Value, key: &str) -> String {
+ payload
+ .get(key)
+ .and_then(|v| v.as_str())
+ .unwrap_or_default()
+ .to_string()
+}
+
+#[async_trait]
+impl EventSink for OpenHumanEventSink {
+ async fn publish(
+ &self,
+ domain: &str,
+ kind: &str,
+ payload: serde_json::Value,
+ ) -> anyhow::Result<()> {
+ match domain {
+ "web" => {
+ let event: crate::core::socketio::WebChannelEvent = serde_json::from_value(payload)
+ .map_err(|e| {
+ anyhow::anyhow!(
+ "{LOG_PREFIX} web event payload not a WebChannelEvent ({kind}): {e}"
+ )
+ })?;
+ crate::openhuman::channels::providers::web::publish_web_channel_event(event);
+ }
+ "channel" => {
+ use crate::core::event_bus::{publish_global, DomainEvent};
+ let event = match kind {
+ "reaction_received" => DomainEvent::ChannelReactionReceived {
+ channel: json_str(&payload, "channel"),
+ sender: json_str(&payload, "sender"),
+ target_message_id: json_str(&payload, "target_message_id"),
+ emoji: json_str(&payload, "emoji"),
+ },
+ "reaction_sent" => DomainEvent::ChannelReactionSent {
+ channel: json_str(&payload, "channel"),
+ target_message_id: json_str(&payload, "target_message_id"),
+ emoji: json_str(&payload, "emoji"),
+ success: payload
+ .get("success")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false),
+ },
+ other => {
+ tracing::warn!("{LOG_PREFIX} unmapped channel event kind: {other}");
+ return Ok(());
+ }
+ };
+ publish_global(event);
+ }
+ other => tracing::warn!("{LOG_PREFIX} unmapped event domain: {other}"),
+ }
+ Ok(())
+ }
+}
diff --git a/src/openhuman/channels/host/mod.rs b/src/openhuman/channels/host/mod.rs
new file mode 100644
index 0000000000..e8f8d8c8e6
--- /dev/null
+++ b/src/openhuman/channels/host/mod.rs
@@ -0,0 +1,65 @@
+//! OpenHuman implementation of the `tinychannels::host` capability boundary.
+//!
+//! [`build_channel_host`] assembles the concrete [`tinychannels::ChannelHost`]
+//! from OpenHuman internals (voice, inference, approvals, conversation store,
+//! shutdown registry, web event bus). [`build_provider_context`] wraps it into
+//! the [`tinychannels::host::ProviderContext`] handed to channel providers.
+//!
+//! Ported providers reach host capabilities through this context instead of
+//! calling OpenHuman internals directly — the inversion that lets them live in
+//! the standalone `tinychannels` crate. Lean providers ignore the host.
+
+mod adapters;
+
+pub use adapters::{
+ ConfigAllowlistStore, ConversationHistoryStore, CoreApprovalGate, CoreShutdownRegistry,
+ InferenceReactionGate, OpenHumanEventSink, VoiceSynthesizer, VoiceTranscriber,
+};
+
+use std::sync::Arc;
+
+use tinychannels::host::{ChannelHostBuilder, ProviderContext};
+use tinychannels::ChannelHost;
+
+use crate::openhuman::config::Config;
+
+/// Assemble the full OpenHuman [`ChannelHost`] from a config snapshot.
+///
+/// Wires every capability OpenHuman can back today: lifecycle (shutdown),
+/// STT, TTS, reaction gate, approval-reply parsing, conversation history, and
+/// the web-channel event sink. Capabilities OpenHuman cannot yet express
+/// portably (turn dispatch, run ledger, pairing) are simply left unset — a
+/// provider that needs one degrades gracefully.
+pub fn build_channel_host(config: Arc) -> Arc {
+ ChannelHostBuilder::new()
+ .lifecycle(Arc::new(CoreShutdownRegistry))
+ .transcriber(Arc::new(VoiceTranscriber {
+ config: Arc::clone(&config),
+ }))
+ .synthesizer(Arc::new(VoiceSynthesizer {
+ config: Arc::clone(&config),
+ }))
+ .reactions(Arc::new(InferenceReactionGate {
+ config: Arc::clone(&config),
+ }))
+ .approvals(Arc::new(CoreApprovalGate))
+ .conversations(Arc::new(ConversationHistoryStore {
+ workspace_dir: config.workspace_dir.clone(),
+ }))
+ .events(Arc::new(OpenHumanEventSink))
+ .allowlist(Arc::new(ConfigAllowlistStore))
+ .build()
+}
+
+/// Build the [`ProviderContext`] handed to a channel provider at construction:
+/// the assembled host + the channels config + a pre-built HTTP client.
+pub fn build_provider_context(config: &Config, http_client: reqwest::Client) -> ProviderContext {
+ ProviderContext::new(
+ build_channel_host(Arc::new(config.clone())),
+ config.channels_config.clone(),
+ http_client,
+ )
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/src/openhuman/channels/host/tests.rs b/src/openhuman/channels/host/tests.rs
new file mode 100644
index 0000000000..6f379799b3
--- /dev/null
+++ b/src/openhuman/channels/host/tests.rs
@@ -0,0 +1,182 @@
+//! Unit tests for the OpenHuman ChannelHost capability adapters.
+
+use super::*;
+use std::sync::Arc;
+use tinychannels::host::{
+ ApprovalDecision, ApprovalGate, ConversationMessage, ConversationStore, EventSink,
+ ReactionGate, ReactionQuery, Transcriber,
+};
+
+use crate::openhuman::config::Config;
+
+// --- ApprovalGate::parse_reply -------------------------------------------
+
+#[test]
+fn approval_gate_maps_core_replies() {
+ let gate = CoreApprovalGate;
+ assert_eq!(gate.parse_reply("yes"), Some(ApprovalDecision::Approve));
+ assert_eq!(gate.parse_reply("APPROVE"), Some(ApprovalDecision::Approve));
+ assert_eq!(gate.parse_reply("no"), Some(ApprovalDecision::Deny));
+ assert_eq!(gate.parse_reply("deny"), Some(ApprovalDecision::Deny));
+ assert_eq!(gate.parse_reply("banana"), None);
+}
+
+// --- ReactionGate (runtime-disabled short-circuit) ------------------------
+
+#[tokio::test]
+async fn reaction_gate_returns_default_when_runtime_disabled() {
+ let mut config = Config::default();
+ config.local_ai.runtime_enabled = false;
+ let gate = InferenceReactionGate {
+ config: Arc::new(config),
+ };
+ let decision = gate
+ .should_react(ReactionQuery {
+ message: "hello".into(),
+ channel_type: "web".into(),
+ })
+ .await
+ .expect("should_react ok");
+ assert!(!decision.should_react);
+ assert!(decision.emoji.is_none());
+}
+
+// --- OpenHumanEventSink --------------------------------------------------
+
+#[tokio::test]
+async fn event_sink_accepts_web_channel_event_shape() {
+ let sink = OpenHumanEventSink;
+ let ok = sink
+ .publish(
+ "web",
+ "chat_done",
+ serde_json::json!({
+ "event": "chat_done",
+ "client_id": "c1",
+ "thread_id": "t1",
+ "request_id": "r1",
+ "full_response": "hi"
+ }),
+ )
+ .await;
+ assert!(ok.is_ok());
+}
+
+#[tokio::test]
+async fn event_sink_rejects_non_object_payload() {
+ let sink = OpenHumanEventSink;
+ let err = sink
+ .publish("web", "bad", serde_json::json!([1, 2, 3]))
+ .await;
+ assert!(err.is_err());
+}
+
+#[tokio::test]
+async fn event_sink_routes_channel_reactions_to_domain_bus() {
+ let sink = OpenHumanEventSink;
+ assert!(sink
+ .publish(
+ "channel",
+ "reaction_received",
+ serde_json::json!({
+ "channel": "telegram", "sender": "u1",
+ "target_message_id": "telegram_1_2", "emoji": "👍"
+ }),
+ )
+ .await
+ .is_ok());
+ assert!(sink
+ .publish(
+ "channel",
+ "reaction_sent",
+ serde_json::json!({
+ "channel": "telegram", "target_message_id": "telegram_1_2",
+ "emoji": "👍", "success": true
+ }),
+ )
+ .await
+ .is_ok());
+ // Unknown domain/kind is a benign no-op.
+ assert!(sink
+ .publish("mystery", "x", serde_json::json!({}))
+ .await
+ .is_ok());
+ assert!(sink
+ .publish("channel", "unknown", serde_json::json!({}))
+ .await
+ .is_ok());
+}
+
+// --- ConversationHistoryStore --------------------------------------------
+
+#[tokio::test]
+async fn conversation_store_append_then_history_roundtrips() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let store = ConversationHistoryStore {
+ workspace_dir: dir.path().to_path_buf(),
+ };
+ let thread = "thread-1";
+
+ store
+ .append(
+ thread,
+ ConversationMessage {
+ role: "user".into(),
+ content: "first".into(),
+ timestamp: None,
+ },
+ )
+ .await
+ .expect("append user");
+ store
+ .append(
+ thread,
+ ConversationMessage {
+ role: "assistant".into(),
+ content: "second".into(),
+ timestamp: None,
+ },
+ )
+ .await
+ .expect("append assistant");
+
+ let history = store.history(thread, 10).await.expect("history");
+ assert_eq!(history.len(), 2);
+ assert_eq!(history[0].content, "first");
+ assert_eq!(history[0].role, "user");
+ assert_eq!(history[1].content, "second");
+ assert_eq!(history[1].role, "assistant");
+
+ // `limit` keeps the most recent messages.
+ let recent = store.history(thread, 1).await.expect("history limited");
+ assert_eq!(recent.len(), 1);
+ assert_eq!(recent[0].content, "second");
+}
+
+// --- build_channel_host ---------------------------------------------------
+
+#[test]
+fn build_channel_host_advertises_expected_capabilities() {
+ let host = build_channel_host(Arc::new(Config::default()));
+ let caps = host.capabilities();
+ assert!(caps.lifecycle);
+ assert!(caps.stt);
+ assert!(caps.tts);
+ assert!(caps.reaction_gate);
+ assert!(caps.approvals);
+ assert!(caps.conversation_store);
+ assert!(caps.event_sink);
+ assert!(caps.allowlist_store);
+ // Not yet backed portably:
+ assert!(!caps.turn_dispatch);
+ assert!(!caps.run_ledger);
+ assert!(!caps.memory_recall);
+}
+
+#[test]
+fn transcriber_reports_stable_name() {
+ let stt = VoiceTranscriber {
+ config: Arc::new(Config::default()),
+ };
+ assert_eq!(stt.name(), "openhuman-voice");
+}
diff --git a/src/openhuman/channels/mod.rs b/src/openhuman/channels/mod.rs
index 057e15f13b..e869a3876a 100644
--- a/src/openhuman/channels/mod.rs
+++ b/src/openhuman/channels/mod.rs
@@ -3,6 +3,7 @@
pub mod bus;
pub mod cli;
pub mod controllers;
+pub mod host;
pub mod proactive;
pub mod providers;
pub(crate) mod relay_runtime;
diff --git a/src/openhuman/channels/providers/dingtalk.rs b/src/openhuman/channels/providers/dingtalk.rs
index 8b935414c2..432fafddb5 100644
--- a/src/openhuman/channels/providers/dingtalk.rs
+++ b/src/openhuman/channels/providers/dingtalk.rs
@@ -1,384 +1 @@
-use crate::openhuman::channels::traits::{Channel, ChannelMessage, SendMessage};
-use async_trait::async_trait;
-use futures_util::{SinkExt, StreamExt};
-use std::collections::HashMap;
-use std::sync::Arc;
-use tokio::sync::RwLock;
-use tokio_tungstenite::tungstenite::Message;
-use uuid::Uuid;
-
-const DINGTALK_BOT_CALLBACK_TOPIC: &str = "/v1.0/im/bot/messages/get";
-
-/// DingTalk channel — connects via Stream Mode WebSocket for real-time messages.
-/// Replies are sent through per-message session webhook URLs.
-pub struct DingTalkChannel {
- client_id: String,
- client_secret: String,
- allowed_users: Vec,
- /// Per-chat session webhooks for sending replies (chatID -> webhook URL).
- /// DingTalk provides a unique webhook URL with each incoming message.
- session_webhooks: Arc>>,
-}
-
-/// Response from DingTalk gateway connection registration.
-#[derive(serde::Deserialize)]
-struct GatewayResponse {
- endpoint: String,
- ticket: String,
-}
-
-impl DingTalkChannel {
- pub fn new(client_id: String, client_secret: String, allowed_users: Vec) -> Self {
- Self {
- client_id,
- client_secret,
- allowed_users,
- session_webhooks: Arc::new(RwLock::new(HashMap::new())),
- }
- }
-
- fn http_client(&self) -> reqwest::Client {
- crate::openhuman::config::build_runtime_proxy_client("channel.dingtalk")
- }
-
- fn is_user_allowed(&self, user_id: &str) -> bool {
- self.allowed_users.iter().any(|u| u == "*" || u == user_id)
- }
-
- fn parse_stream_data(frame: &serde_json::Value) -> Option {
- match frame.get("data") {
- Some(serde_json::Value::String(raw)) => serde_json::from_str(raw).ok(),
- Some(serde_json::Value::Object(_)) => frame.get("data").cloned(),
- _ => None,
- }
- }
-
- fn resolve_chat_id(data: &serde_json::Value, sender_id: &str) -> String {
- let is_private_chat = data
- .get("conversationType")
- .and_then(|value| {
- value
- .as_str()
- .map(|v| v == "1")
- .or_else(|| value.as_i64().map(|v| v == 1))
- })
- .unwrap_or(true);
-
- if is_private_chat {
- sender_id.to_string()
- } else {
- data.get("conversationId")
- .and_then(|c| c.as_str())
- .unwrap_or(sender_id)
- .to_string()
- }
- }
-
- /// Register a connection with DingTalk's gateway to get a WebSocket endpoint.
- async fn register_connection(&self) -> anyhow::Result {
- let body = serde_json::json!({
- "clientId": self.client_id,
- "clientSecret": self.client_secret,
- "subscriptions": [
- {
- "type": "CALLBACK",
- "topic": DINGTALK_BOT_CALLBACK_TOPIC,
- }
- ],
- });
-
- let resp = self
- .http_client()
- .post("https://api.dingtalk.com/v1.0/gateway/connections/open")
- .json(&body)
- .send()
- .await?;
-
- if !resp.status().is_success() {
- let status = resp.status();
- let err = resp.text().await.unwrap_or_default();
- anyhow::bail!("DingTalk gateway registration failed ({status}): {err}");
- }
-
- let gw: GatewayResponse = resp.json().await?;
- Ok(gw)
- }
-}
-
-#[async_trait]
-impl Channel for DingTalkChannel {
- fn name(&self) -> &str {
- "dingtalk"
- }
-
- async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
- let webhooks = self.session_webhooks.read().await;
- let webhook_url = webhooks.get(&message.recipient).ok_or_else(|| {
- anyhow::anyhow!(
- "No session webhook found for chat {}. \
- The user must send a message first to establish a session.",
- message.recipient
- )
- })?;
-
- let title = message.subject.as_deref().unwrap_or("OpenHuman");
- let body = serde_json::json!({
- "msgtype": "markdown",
- "markdown": {
- "title": title,
- "text": message.content,
- }
- });
-
- let resp = self
- .http_client()
- .post(webhook_url)
- .json(&body)
- .send()
- .await?;
-
- if !resp.status().is_success() {
- let status = resp.status();
- let err = resp.text().await.unwrap_or_default();
- anyhow::bail!("DingTalk webhook reply failed ({status}): {err}");
- }
-
- Ok(())
- }
-
- async fn listen(&self, tx: tokio::sync::mpsc::Sender) -> anyhow::Result<()> {
- tracing::info!("DingTalk: registering gateway connection...");
-
- let gw = self.register_connection().await?;
- let ws_url = format!("{}?ticket={}", gw.endpoint, gw.ticket);
-
- tracing::info!("DingTalk: connecting to stream WebSocket...");
- let (ws_stream, _) = tokio_tungstenite::connect_async(&ws_url).await?;
- let (mut write, mut read) = ws_stream.split();
-
- tracing::info!("DingTalk: connected and listening for messages...");
-
- while let Some(msg) = read.next().await {
- let msg = match msg {
- Ok(Message::Text(t)) => t,
- Ok(Message::Close(_)) => break,
- Err(e) => {
- tracing::warn!("DingTalk WebSocket error: {e}");
- break;
- }
- _ => continue,
- };
-
- let frame: serde_json::Value = match serde_json::from_str(msg.as_ref()) {
- Ok(v) => v,
- Err(_) => continue,
- };
-
- let frame_type = frame.get("type").and_then(|t| t.as_str()).unwrap_or("");
-
- match frame_type {
- "SYSTEM" => {
- // Respond to system pings to keep the connection alive
- let message_id = frame
- .get("headers")
- .and_then(|h| h.get("messageId"))
- .and_then(|m| m.as_str())
- .unwrap_or("");
-
- let pong = serde_json::json!({
- "code": 200,
- "headers": {
- "contentType": "application/json",
- "messageId": message_id,
- },
- "message": "OK",
- "data": "",
- });
-
- if let Err(e) = write.send(Message::Text(pong.to_string())).await {
- tracing::warn!("DingTalk: failed to send pong: {e}");
- break;
- }
- }
- "EVENT" | "CALLBACK" => {
- // Parse the chatbot callback data from the frame.
- let data = match Self::parse_stream_data(&frame) {
- Some(v) => v,
- None => {
- tracing::debug!("DingTalk: frame has no parseable data payload");
- continue;
- }
- };
-
- // Extract message content
- let content = data
- .get("text")
- .and_then(|t| t.get("content"))
- .and_then(|c| c.as_str())
- .unwrap_or("")
- .trim();
-
- if content.is_empty() {
- continue;
- }
-
- let sender_id = data
- .get("senderStaffId")
- .and_then(|s| s.as_str())
- .unwrap_or("unknown");
-
- if !self.is_user_allowed(sender_id) {
- tracing::warn!(
- "DingTalk: ignoring message from unauthorized user: {sender_id}"
- );
- continue;
- }
-
- // Private chat uses sender ID, group chat uses conversation ID.
- let chat_id = Self::resolve_chat_id(&data, sender_id);
-
- // Store session webhook for later replies
- if let Some(webhook) = data.get("sessionWebhook").and_then(|w| w.as_str()) {
- let webhook = webhook.to_string();
- let mut webhooks = self.session_webhooks.write().await;
- // Use both keys so reply routing works for both group and private flows.
- webhooks.insert(chat_id.clone(), webhook.clone());
- webhooks.insert(sender_id.to_string(), webhook);
- }
-
- // Acknowledge the event
- let message_id = frame
- .get("headers")
- .and_then(|h| h.get("messageId"))
- .and_then(|m| m.as_str())
- .unwrap_or("");
-
- let ack = serde_json::json!({
- "code": 200,
- "headers": {
- "contentType": "application/json",
- "messageId": message_id,
- },
- "message": "OK",
- "data": "",
- });
- let _ = write.send(Message::Text(ack.to_string())).await;
-
- let channel_msg = ChannelMessage {
- id: Uuid::new_v4().to_string(),
- sender: sender_id.to_string(),
- reply_target: chat_id,
- content: content.to_string(),
- channel: "dingtalk".to_string(),
- timestamp: std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .unwrap_or_default()
- .as_secs(),
- thread_ts: None,
- };
-
- if tx.send(channel_msg).await.is_err() {
- tracing::warn!("DingTalk: message channel closed");
- break;
- }
- }
- _ => {}
- }
- }
-
- anyhow::bail!("DingTalk WebSocket stream ended")
- }
-
- async fn health_check(&self) -> bool {
- self.register_connection().await.is_ok()
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn test_name() {
- let ch = DingTalkChannel::new("id".into(), "secret".into(), vec![]);
- assert_eq!(ch.name(), "dingtalk");
- }
-
- #[test]
- fn test_user_allowed_wildcard() {
- let ch = DingTalkChannel::new("id".into(), "secret".into(), vec!["*".into()]);
- assert!(ch.is_user_allowed("anyone"));
- }
-
- #[test]
- fn test_user_allowed_specific() {
- let ch = DingTalkChannel::new("id".into(), "secret".into(), vec!["user123".into()]);
- assert!(ch.is_user_allowed("user123"));
- assert!(!ch.is_user_allowed("other"));
- }
-
- #[test]
- fn test_user_denied_empty() {
- let ch = DingTalkChannel::new("id".into(), "secret".into(), vec![]);
- assert!(!ch.is_user_allowed("anyone"));
- }
-
- #[test]
- fn test_config_serde() {
- let toml_str = r#"
-client_id = "app_id_123"
-client_secret = "secret_456"
-allowed_users = ["user1", "*"]
-"#;
- let config: crate::openhuman::config::schema::DingTalkConfig =
- toml::from_str(toml_str).unwrap();
- assert_eq!(config.client_id, "app_id_123");
- assert_eq!(config.client_secret, "secret_456");
- assert_eq!(config.allowed_users, vec!["user1", "*"]);
- }
-
- #[test]
- fn test_config_serde_defaults() {
- let toml_str = r#"
-client_id = "id"
-client_secret = "secret"
-"#;
- let config: crate::openhuman::config::schema::DingTalkConfig =
- toml::from_str(toml_str).unwrap();
- assert!(config.allowed_users.is_empty());
- }
-
- #[test]
- fn parse_stream_data_supports_string_payload() {
- let frame = serde_json::json!({
- "data": "{\"text\":{\"content\":\"hello\"}}"
- });
- let parsed = DingTalkChannel::parse_stream_data(&frame).unwrap();
- assert_eq!(
- parsed.get("text").and_then(|v| v.get("content")),
- Some(&serde_json::json!("hello"))
- );
- }
-
- #[test]
- fn parse_stream_data_supports_object_payload() {
- let frame = serde_json::json!({
- "data": {"text": {"content": "hello"}}
- });
- let parsed = DingTalkChannel::parse_stream_data(&frame).unwrap();
- assert_eq!(
- parsed.get("text").and_then(|v| v.get("content")),
- Some(&serde_json::json!("hello"))
- );
- }
-
- #[test]
- fn resolve_chat_id_handles_numeric_group_conversation_type() {
- let data = serde_json::json!({
- "conversationType": 2,
- "conversationId": "cid-group",
- });
- let chat_id = DingTalkChannel::resolve_chat_id(&data, "staff-1");
- assert_eq!(chat_id, "cid-group");
- }
-}
+pub use tinychannels::providers::dingtalk::*;
diff --git a/src/openhuman/channels/providers/discord.rs b/src/openhuman/channels/providers/discord.rs
new file mode 100644
index 0000000000..83ce3b2d39
--- /dev/null
+++ b/src/openhuman/channels/providers/discord.rs
@@ -0,0 +1 @@
+pub use tinychannels::providers::discord::*;
diff --git a/src/openhuman/channels/providers/discord/api.rs b/src/openhuman/channels/providers/discord/api.rs
deleted file mode 100644
index 7b9ca8556b..0000000000
--- a/src/openhuman/channels/providers/discord/api.rs
+++ /dev/null
@@ -1,511 +0,0 @@
-//! Discord REST API helpers for guild/channel discovery and permission checks.
-
-use serde::{Deserialize, Serialize};
-
-const DISCORD_API_BASE: &str = "https://discord.com/api/v10";
-
-/// Minimal guild (server) info returned by `GET /users/@me/guilds`.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct DiscordGuild {
- pub id: String,
- pub name: String,
- pub icon: Option,
-}
-
-/// Minimal channel info returned by `GET /guilds/{guild_id}/channels`.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct DiscordTextChannel {
- pub id: String,
- pub name: String,
- /// Discord channel type — 0 = text, 2 = voice, 4 = category, etc.
- #[serde(rename = "type")]
- pub channel_type: u64,
- #[serde(default)]
- pub position: u64,
- /// Parent category ID (if nested under a category).
- pub parent_id: Option,
-}
-
-/// Result of a bot permission check for a given channel.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct BotPermissionCheck {
- pub can_view_channel: bool,
- pub can_send_messages: bool,
- pub can_read_message_history: bool,
- pub missing_permissions: Vec,
-}
-
-// Discord permission flag bits
-const VIEW_CHANNEL: u64 = 1 << 10; // 0x400
-const SEND_MESSAGES: u64 = 1 << 11; // 0x800
-const READ_MESSAGE_HISTORY: u64 = 1 << 16; // 0x10000
-
-fn build_client() -> reqwest::Client {
- crate::openhuman::config::build_runtime_proxy_client("channel.discord")
-}
-
-fn auth_header(token: &str) -> String {
- format!("Bot {token}")
-}
-
-/// Format a non-2xx response from the Discord REST API as a string
-/// suitable for a JSON-RPC error result.
-///
-/// **Load-bearing for issue #2285**: the global JSON-RPC dispatcher
-/// at `src/core/jsonrpc.rs::is_session_expired_error` matches any
-/// error string that contains `"401"` AND `"unauthorized"` as a
-/// signal that the OpenHuman backend session has expired, and
-/// publishes a `DomainEvent::SessionExpired` event that signs the
-/// user out. A raw upstream Discord 401 (revoked bot token) would
-/// previously trip that classifier — opening the connected-Discord
-/// card on the Channels page logged the user out of OpenHuman over
-/// a *Discord* credentials problem.
-///
-/// The fix is to convert auth failures here into a Discord-domain
-/// message that:
-///
-/// 1. Does NOT contain both `"401"` and `"unauthorized"` as a pair
-/// (so the global classifier ignores it).
-/// 2. Tells the user the actual remediation: rotate the bot token
-/// at `Settings → Channels → Discord`.
-/// 3. Preserves the originating endpoint identifier in the message
-/// so triage can still see WHICH Discord call failed without
-/// plumbing a separate error code.
-///
-/// Other non-2xx statuses (400 / 404 / 5xx) pass through with a
-/// `Discord API error` prefix — they don't match the
-/// `is_session_expired_error` predicate even when verbatim.
-fn format_discord_http_error(endpoint: &str, status: reqwest::StatusCode, body: &str) -> String {
- if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
- let kind = if status == reqwest::StatusCode::UNAUTHORIZED {
- "bot token was rejected"
- } else {
- "bot token lacks required Discord permissions"
- };
- // Spell out the HTTP code so `lower.contains("401")` does NOT
- // match — see #2285 rationale on this helper. Also avoid the
- // word `unauthorized` for the same reason; "rejected"/"forbidden"
- // are the user-visible equivalents.
- let code_word = if status == reqwest::StatusCode::UNAUTHORIZED {
- "four-oh-one"
- } else {
- "four-oh-three"
- };
- // Deliberately do NOT splice the upstream body into this
- // user-facing message — Discord's auth-error bodies often
- // include the literal words "401" and "Unauthorized", which
- // would smuggle the cascade trigger back in. The body is
- // still in `tracing::debug!` logs above the call site for
- // triage; the user-facing message only needs the remediation.
- let _ = body;
- format!(
- "Discord {endpoint}: {kind} (upstream HTTP {code_word}). \
- Open Settings → Channels → Discord and rotate / reconnect the bot \
- token."
- )
- } else {
- format!("Discord {endpoint} failed ({status}): {body}")
- }
-}
-
-/// List all guilds (servers) the bot is a member of.
-pub async fn list_bot_guilds(token: &str) -> anyhow::Result> {
- list_bot_guilds_at_base(DISCORD_API_BASE, token).await
-}
-
-/// Test seam: list guilds against an arbitrary API base. Used by
-/// `list_bot_guilds` in production and by unit tests that drive a
-/// local mock Discord API.
-async fn list_bot_guilds_at_base(base: &str, token: &str) -> anyhow::Result> {
- let url = format!("{base}/users/@me/guilds");
- tracing::debug!("[discord-api] listing guilds for bot");
-
- let resp = build_client()
- .get(&url)
- .header("Authorization", auth_header(token))
- .send()
- .await?;
-
- if !resp.status().is_success() {
- let status = resp.status();
- let body = resp.text().await.unwrap_or_default();
- tracing::debug!(
- target: "discord-api",
- endpoint = "list_guilds",
- %url,
- %status,
- body = %body,
- "[discord-api] non-success response"
- );
- anyhow::bail!(
- "{}",
- format_discord_http_error("list_guilds", status, &body)
- );
- }
-
- let guilds: Vec = resp.json().await?;
- tracing::debug!("[discord-api] found {} guilds", guilds.len());
- Ok(guilds)
-}
-
-/// List text channels in a guild. Filters to type=0 (text channels) only.
-pub async fn list_guild_channels(
- token: &str,
- guild_id: &str,
-) -> anyhow::Result> {
- list_guild_channels_at_base(DISCORD_API_BASE, token, guild_id).await
-}
-
-/// Test seam: list guild channels against an arbitrary API base.
-async fn list_guild_channels_at_base(
- base: &str,
- token: &str,
- guild_id: &str,
-) -> anyhow::Result> {
- let url = format!("{base}/guilds/{guild_id}/channels");
- tracing::debug!("[discord-api] listing channels for guild {guild_id}");
-
- let resp = build_client()
- .get(&url)
- .header("Authorization", auth_header(token))
- .send()
- .await?;
-
- if !resp.status().is_success() {
- let status = resp.status();
- let body = resp.text().await.unwrap_or_default();
- tracing::debug!(
- target: "discord-api",
- endpoint = "list_guild_channels",
- %guild_id,
- %url,
- %status,
- body = %body,
- "[discord-api] non-success response"
- );
- anyhow::bail!(
- "{}",
- format_discord_http_error("list_channels", status, &body)
- );
- }
-
- let all_channels: Vec = resp.json().await?;
-
- // Filter to text channels (type 0) and sort by position
- let mut text_channels: Vec = all_channels
- .into_iter()
- .filter(|c| c.channel_type == 0)
- .collect();
- text_channels.sort_by_key(|c| c.position);
-
- tracing::debug!(
- "[discord-api] found {} text channels in guild {guild_id}",
- text_channels.len()
- );
- Ok(text_channels)
-}
-
-/// Check bot permissions in a specific channel.
-///
-/// Uses `GET /channels/{channel_id}` combined with the bot's guild member
-/// permissions to determine if the bot can view, send, and read history.
-pub async fn check_channel_permissions(
- token: &str,
- guild_id: &str,
- channel_id: &str,
-) -> anyhow::Result {
- check_channel_permissions_at_base(DISCORD_API_BASE, token, guild_id, channel_id).await
-}
-
-/// Test seam: see [`check_channel_permissions`].
-async fn check_channel_permissions_at_base(
- base: &str,
- token: &str,
- guild_id: &str,
- channel_id: &str,
-) -> anyhow::Result {
- tracing::debug!(
- "[discord-api] checking permissions in channel {channel_id} (guild {guild_id})"
- );
-
- // Resolve bot user id first (`members/@me` is not a valid Discord route).
- let me_url = format!("{base}/users/@me");
- let me_resp = build_client()
- .get(&me_url)
- .header("Authorization", auth_header(token))
- .send()
- .await?;
-
- if !me_resp.status().is_success() {
- let status = me_resp.status();
- let body = me_resp.text().await.unwrap_or_default();
- tracing::debug!(
- target: "discord-api",
- endpoint = "check_bot_permissions.me",
- %guild_id,
- %channel_id,
- url = %me_url,
- %status,
- body = %body,
- "[discord-api] non-success response"
- );
- anyhow::bail!(
- "{}",
- format_discord_http_error("get_bot_user", status, &body)
- );
- }
- let me: serde_json::Value = me_resp.json().await?;
- let bot_user_id = me.get("id").and_then(|i| i.as_str()).unwrap_or("").trim();
- if bot_user_id.is_empty() {
- anyhow::bail!("Discord get bot user returned empty id");
- }
-
- // Fetch the bot's guild member info which includes role ids.
- let member_url = format!("{base}/guilds/{guild_id}/members/{bot_user_id}");
- let member_resp = build_client()
- .get(&member_url)
- .header("Authorization", auth_header(token))
- .send()
- .await?;
-
- if !member_resp.status().is_success() {
- let status = member_resp.status();
- let body = member_resp.text().await.unwrap_or_default();
- tracing::debug!(
- target: "discord-api",
- endpoint = "check_bot_permissions.member",
- %guild_id,
- %channel_id,
- url = %member_url,
- %status,
- body = %body,
- "[discord-api] non-success response"
- );
- anyhow::bail!(
- "{}",
- format_discord_http_error("get_member_info", status, &body)
- );
- }
-
- let member: serde_json::Value = member_resp.json().await?;
-
- // Fetch guild roles to compute permissions
- let roles_url = format!("{base}/guilds/{guild_id}/roles");
- let roles_resp = build_client()
- .get(&roles_url)
- .header("Authorization", auth_header(token))
- .send()
- .await?;
- if !roles_resp.status().is_success() {
- let status = roles_resp.status();
- let body = roles_resp.text().await.unwrap_or_default();
- tracing::debug!(
- target: "discord-api",
- endpoint = "check_bot_permissions.roles",
- %guild_id,
- %channel_id,
- url = %roles_url,
- %status,
- body = %body,
- "[discord-api] non-success response"
- );
- anyhow::bail!(
- "{}",
- format_discord_http_error("get_guild_roles", status, &body)
- );
- }
- let guild_roles: Vec = roles_resp.json().await?;
-
- // Get the member's role IDs
- let member_role_ids: Vec<&str> = member
- .get("roles")
- .and_then(|r| r.as_array())
- .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::>())
- .unwrap_or_default();
-
- // Compute base permissions from @everyone role + member roles
- let mut permissions: u64 = 0;
- for role in &guild_roles {
- let role_id = role.get("id").and_then(|i| i.as_str()).unwrap_or("");
- let is_everyone = role_id == guild_id; // @everyone role ID == guild ID
- let is_member_role = member_role_ids.contains(&role_id);
-
- if is_everyone || is_member_role {
- if let Some(perms_str) = role.get("permissions").and_then(|p| p.as_str()) {
- if let Ok(perms) = perms_str.parse::() {
- permissions |= perms;
- }
- }
- }
- }
-
- // Administrator bypasses all permission checks
- const ADMINISTRATOR: u64 = 1 << 3;
- if permissions & ADMINISTRATOR != 0 {
- return Ok(BotPermissionCheck {
- can_view_channel: true,
- can_send_messages: true,
- can_read_message_history: true,
- missing_permissions: vec![],
- });
- }
-
- // Now check channel-level permission overwrites
- let channel_url = format!("{base}/channels/{channel_id}");
- let ch_resp = build_client()
- .get(&channel_url)
- .header("Authorization", auth_header(token))
- .send()
- .await?;
- if !ch_resp.status().is_success() {
- let status = ch_resp.status();
- let body = ch_resp.text().await.unwrap_or_default();
- tracing::debug!(
- target: "discord-api",
- endpoint = "check_bot_permissions.channel",
- %guild_id,
- %channel_id,
- url = %channel_url,
- %status,
- body = %body,
- "[discord-api] non-success response"
- );
- anyhow::bail!(
- "{}",
- format_discord_http_error("get_channel", status, &body)
- );
- }
- let channel_data: serde_json::Value = ch_resp.json().await?;
- if let Some(overwrites) = channel_data
- .get("permission_overwrites")
- .and_then(|o| o.as_array())
- {
- // Intentional shadowing: prefer the ID returned inside the member
- // object over the one fetched from /users/@me, because the guild
- // member record is more authoritative for permission overwrite lookups.
- let bot_user_id = member
- .get("user")
- .and_then(|u| u.get("id"))
- .and_then(|i| i.as_str())
- .unwrap_or(bot_user_id);
-
- let mut everyone_allow = 0_u64;
- let mut everyone_deny = 0_u64;
- let mut role_allow = 0_u64;
- let mut role_deny = 0_u64;
- let mut member_allow = 0_u64;
- let mut member_deny = 0_u64;
-
- for overwrite in overwrites {
- let ow_id = overwrite.get("id").and_then(|i| i.as_str()).unwrap_or("");
- let ow_type = overwrite.get("type").and_then(|t| t.as_u64()).unwrap_or(0);
- let allow = overwrite
- .get("allow")
- .and_then(|a| a.as_str())
- .and_then(|s| s.parse::().ok())
- .unwrap_or(0);
- let deny = overwrite
- .get("deny")
- .and_then(|d| d.as_str())
- .and_then(|s| s.parse::().ok())
- .unwrap_or(0);
-
- match ow_type {
- // @everyone overwrite (role id == guild id)
- 0 if ow_id == guild_id => {
- everyone_allow = allow;
- everyone_deny = deny;
- }
- // Aggregate all role overwrites
- 0 if member_role_ids.contains(&ow_id) => {
- role_allow |= allow;
- role_deny |= deny;
- }
- // Member-specific overwrite
- 1 if ow_id == bot_user_id => {
- member_allow = allow;
- member_deny = deny;
- }
- _ => {}
- }
- }
-
- // Apply Discord overwrite precedence: everyone -> roles -> member.
- permissions &= !everyone_deny;
- permissions |= everyone_allow;
- permissions &= !role_deny;
- permissions |= role_allow;
- permissions &= !member_deny;
- permissions |= member_allow;
- }
-
- let can_view = permissions & VIEW_CHANNEL != 0;
- let can_send = permissions & SEND_MESSAGES != 0;
- let can_read_history = permissions & READ_MESSAGE_HISTORY != 0;
-
- let mut missing = Vec::new();
- if !can_view {
- missing.push("VIEW_CHANNEL".to_string());
- }
- if !can_send {
- missing.push("SEND_MESSAGES".to_string());
- }
- if !can_read_history {
- missing.push("READ_MESSAGE_HISTORY".to_string());
- }
-
- tracing::debug!(
- "[discord-api] permissions for channel {channel_id}: view={can_view}, send={can_send}, history={can_read_history}"
- );
-
- Ok(BotPermissionCheck {
- can_view_channel: can_view,
- can_send_messages: can_send,
- can_read_message_history: can_read_history,
- missing_permissions: missing,
- })
-}
-
-#[cfg(test)]
-#[path = "api_tests.rs"]
-mod tests;
-
-#[cfg(any(test, debug_assertions))]
-pub mod test_support {
- //! Debug-build wrappers for raw integration tests that drive the Discord
- //! REST helpers against loopback servers.
-
- use super::*;
-
- pub fn format_discord_http_error_for_test(
- endpoint: &str,
- status: reqwest::StatusCode,
- body: &str,
- ) -> String {
- format_discord_http_error(endpoint, status, body)
- }
-
- pub async fn list_bot_guilds_at_base_for_test(
- base: &str,
- token: &str,
- ) -> anyhow::Result> {
- list_bot_guilds_at_base(base, token).await
- }
-
- pub async fn list_guild_channels_at_base_for_test(
- base: &str,
- token: &str,
- guild_id: &str,
- ) -> anyhow::Result> {
- list_guild_channels_at_base(base, token, guild_id).await
- }
-
- pub async fn check_channel_permissions_at_base_for_test(
- base: &str,
- token: &str,
- guild_id: &str,
- channel_id: &str,
- ) -> anyhow::Result {
- check_channel_permissions_at_base(base, token, guild_id, channel_id).await
- }
-}
diff --git a/src/openhuman/channels/providers/discord/api_tests.rs b/src/openhuman/channels/providers/discord/api_tests.rs
deleted file mode 100644
index b222e15f08..0000000000
--- a/src/openhuman/channels/providers/discord/api_tests.rs
+++ /dev/null
@@ -1,451 +0,0 @@
-use super::*;
-
-#[test]
-fn guild_deserializes() {
- let json = r#"{"id":"123","name":"Test Server","icon":"abc123"}"#;
- let guild: DiscordGuild = serde_json::from_str(json).unwrap();
- assert_eq!(guild.id, "123");
- assert_eq!(guild.name, "Test Server");
- assert_eq!(guild.icon, Some("abc123".to_string()));
-}
-
-#[test]
-fn guild_deserializes_without_icon() {
- let json = r#"{"id":"456","name":"No Icon","icon":null}"#;
- let guild: DiscordGuild = serde_json::from_str(json).unwrap();
- assert_eq!(guild.id, "456");
- assert!(guild.icon.is_none());
-}
-
-#[test]
-fn text_channel_deserializes() {
- let json = r#"{"id":"789","name":"general","type":0,"position":1,"parent_id":"100"}"#;
- let ch: DiscordTextChannel = serde_json::from_str(json).unwrap();
- assert_eq!(ch.id, "789");
- assert_eq!(ch.name, "general");
- assert_eq!(ch.channel_type, 0);
- assert_eq!(ch.position, 1);
- assert_eq!(ch.parent_id, Some("100".to_string()));
-}
-
-#[test]
-fn text_channel_without_parent() {
- let json = r#"{"id":"789","name":"general","type":0,"position":0,"parent_id":null}"#;
- let ch: DiscordTextChannel = serde_json::from_str(json).unwrap();
- assert!(ch.parent_id.is_none());
-}
-
-#[test]
-fn permission_check_serializes() {
- let check = BotPermissionCheck {
- can_view_channel: true,
- can_send_messages: true,
- can_read_message_history: false,
- missing_permissions: vec!["READ_MESSAGE_HISTORY".to_string()],
- };
- let json = serde_json::to_string(&check).unwrap();
- assert!(json.contains("READ_MESSAGE_HISTORY"));
-}
-
-#[test]
-fn permission_bits_are_correct() {
- assert_eq!(VIEW_CHANNEL, 1024);
- assert_eq!(SEND_MESSAGES, 2048);
- assert_eq!(READ_MESSAGE_HISTORY, 65536);
-}
-
-#[test]
-fn auth_header_has_bot_prefix() {
- assert_eq!(auth_header("abc"), "Bot abc");
- assert_eq!(auth_header(""), "Bot ");
-}
-
-#[test]
-fn permission_check_lists_all_missing_permissions_when_bot_lacks_any() {
- let check = BotPermissionCheck {
- can_view_channel: false,
- can_send_messages: false,
- can_read_message_history: false,
- missing_permissions: vec![
- "VIEW_CHANNEL".into(),
- "SEND_MESSAGES".into(),
- "READ_MESSAGE_HISTORY".into(),
- ],
- };
- let json = serde_json::to_string(&check).unwrap();
- assert!(json.contains("VIEW_CHANNEL"));
- assert!(json.contains("SEND_MESSAGES"));
- assert!(json.contains("READ_MESSAGE_HISTORY"));
-}
-
-#[test]
-fn permission_check_with_all_granted_has_empty_missing_list() {
- let check = BotPermissionCheck {
- can_view_channel: true,
- can_send_messages: true,
- can_read_message_history: true,
- missing_permissions: vec![],
- };
- let json = serde_json::to_string(&check).unwrap();
- assert!(json.contains("\"missing_permissions\":[]"));
-}
-
-#[test]
-fn text_channel_type_zero_is_standard_text() {
- let json = r#"{"id":"1","name":"general","type":0,"position":0,"parent_id":null}"#;
- let ch: DiscordTextChannel = serde_json::from_str(json).unwrap();
- assert_eq!(ch.channel_type, 0);
-}
-
-#[test]
-fn guild_deserializes_with_full_payload() {
- let json = r#"{
- "id": "999",
- "name": "Full Guild",
- "icon": "hash"
- }"#;
- let g: DiscordGuild = serde_json::from_str(json).unwrap();
- assert_eq!(g.id, "999");
- assert_eq!(g.name, "Full Guild");
-}
-
-#[test]
-fn permission_bit_flags_are_disjoint() {
- // Sanity: each permission is a single bit and distinct.
- assert_eq!(VIEW_CHANNEL.count_ones(), 1);
- assert_eq!(SEND_MESSAGES.count_ones(), 1);
- assert_eq!(READ_MESSAGE_HISTORY.count_ones(), 1);
- assert_ne!(VIEW_CHANNEL, SEND_MESSAGES);
- assert_ne!(SEND_MESSAGES, READ_MESSAGE_HISTORY);
-}
-
-// ── Mock Discord server integration tests ──────────────────────
-
-use axum::{extract::Path, http::StatusCode, routing::get, Json, Router};
-use serde_json::json;
-
-async fn spawn_mock(app: Router) -> String {
- let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
- let addr = listener.local_addr().unwrap();
- tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
- format!("http://127.0.0.1:{}", addr.port())
-}
-
-#[tokio::test]
-async fn list_bot_guilds_parses_discord_response() {
- let app = Router::new().route(
- "/users/@me/guilds",
- get(|| async {
- Json(json!([
- {"id": "g1", "name": "Guild One", "icon": "hash1"},
- {"id": "g2", "name": "Guild Two", "icon": null}
- ]))
- }),
- );
- let base = spawn_mock(app).await;
- let guilds = list_bot_guilds_at_base(&base, "test-token").await.unwrap();
- assert_eq!(guilds.len(), 2);
- assert_eq!(guilds[0].id, "g1");
- assert_eq!(guilds[0].name, "Guild One");
- assert_eq!(guilds[1].icon, None);
-}
-
-#[tokio::test]
-async fn list_bot_guilds_rewraps_401_so_global_session_cascade_does_not_fire() {
- // Upstream returns 401 with the canonical Discord auth-error body.
- // BEFORE #2285 the error string flowed up to JSON-RPC as
- // "Discord list guilds failed (401 Unauthorized): {\"message\":
- // \"401: Unauthorized\",\"code\":0}" — that pair tripped
- // `jsonrpc::is_session_expired_error` ("401" + "unauthorized")
- // and logged the user out of OpenHuman over a *Discord*
- // credentials problem.
- //
- // After the fix the user-facing message:
- // - does NOT contain "401" or "unauthorized" as substrings
- // (so `is_session_expired_error` returns false), AND
- // - names the endpoint + the actionable Settings → Channels →
- // Discord remediation path.
- let app = Router::new().route(
- "/users/@me/guilds",
- get(|| async {
- (
- StatusCode::UNAUTHORIZED,
- r#"{"message":"401: Unauthorized","code":0}"#,
- )
- }),
- );
- let base = spawn_mock(app).await;
- let err = list_bot_guilds_at_base(&base, "t")
- .await
- .unwrap_err()
- .to_string();
- let lower = err.to_ascii_lowercase();
- assert!(
- !lower.contains("401"),
- "must NOT contain '401' substring: {err}"
- );
- assert!(
- !lower.contains("unauthorized"),
- "must NOT contain 'unauthorized' substring: {err}"
- );
- assert!(
- err.contains("list_guilds"),
- "endpoint identifier preserved for triage: {err}"
- );
- assert!(
- err.contains("Settings → Channels → Discord"),
- "remediation path present: {err}"
- );
-}
-
-#[tokio::test]
-async fn list_bot_guilds_5xx_still_carries_raw_status() {
- // Non-auth errors fall through to the legacy verbose format —
- // those don't match `is_session_expired_error` even verbatim, so
- // surfacing the raw status code helps the user / triage.
- let app = Router::new().route(
- "/users/@me/guilds",
- get(|| async { (StatusCode::INTERNAL_SERVER_ERROR, "discord melting") }),
- );
- let base = spawn_mock(app).await;
- let err = list_bot_guilds_at_base(&base, "t")
- .await
- .unwrap_err()
- .to_string();
- assert!(
- err.contains("500"),
- "5xx must surface verbatim status: {err}"
- );
- assert!(err.contains("list_guilds"));
-}
-
-#[tokio::test]
-async fn list_guild_channels_filters_text_channels_and_sorts_by_position() {
- let app = Router::new().route(
- "/guilds/{guild_id}/channels",
- get(|Path(guild_id): Path| async move {
- assert_eq!(guild_id, "g1");
- Json(json!([
- {"id": "c3", "name": "category", "type": 4, "position": 0, "parent_id": null},
- {"id": "c1", "name": "general", "type": 0, "position": 2, "parent_id": null},
- {"id": "c2", "name": "random", "type": 0, "position": 1, "parent_id": null},
- {"id": "c4", "name": "voice", "type": 2, "position": 3, "parent_id": null}
- ]))
- }),
- );
- let base = spawn_mock(app).await;
- let channels = list_guild_channels_at_base(&base, "t", "g1").await.unwrap();
- // Only text channels (type=0) remain, sorted by position ascending.
- assert_eq!(channels.len(), 2);
- assert_eq!(channels[0].id, "c2");
- assert_eq!(channels[1].id, "c1");
-}
-
-#[tokio::test]
-async fn list_guild_channels_rewraps_403_with_remediation_and_no_session_keywords() {
- // 403 follows the same rewrap path as 401 (#2285) — both can
- // happen on a stale/disabled bot token AND both share enough
- // substrings with `is_session_expired_error` to be a problem if
- // raw upstream text reaches the JSON-RPC layer. The user-facing
- // message must use the safer wording.
- let app = Router::new().route(
- "/guilds/{guild_id}/channels",
- get(|| async {
- (
- StatusCode::FORBIDDEN,
- r#"{"message":"Missing Access","code":50001}"#,
- )
- }),
- );
- let base = spawn_mock(app).await;
- let err = list_guild_channels_at_base(&base, "t", "g1")
- .await
- .unwrap_err()
- .to_string();
- let lower = err.to_ascii_lowercase();
- assert!(!lower.contains("403"), "raw 403 must not leak: {err}");
- assert!(
- err.contains("list_channels"),
- "endpoint identifier preserved: {err}"
- );
- assert!(
- err.contains("Settings → Channels → Discord"),
- "remediation path present: {err}"
- );
-}
-
-#[tokio::test]
-async fn list_guild_channels_empty_returns_empty_vec() {
- let app = Router::new().route(
- "/guilds/{guild_id}/channels",
- get(|| async { Json(json!([])) }),
- );
- let base = spawn_mock(app).await;
- let channels = list_guild_channels_at_base(&base, "t", "g").await.unwrap();
- assert!(channels.is_empty());
-}
-
-// ── check_channel_permissions ─────────────────────────────────
-
-/// Build a mock Discord that answers all endpoints the permissions check
-/// touches: `/users/@me`, `/guilds//members/`,
-/// `/guilds//roles`, and `/channels/`.
-fn permissions_mock(
- member: serde_json::Value,
- roles: serde_json::Value,
- channel: serde_json::Value,
-) -> Router {
- use axum::extract::Path;
- Router::new()
- .route(
- "/users/@me",
- get(|| async { Json(json!({ "id": "bot-1" })) }),
- )
- .route(
- "/guilds/{guild_id}/members/{member_id}",
- get(move |Path((_g, member_id)): Path<(String, String)>| {
- assert_eq!(member_id, "bot-1");
- let m = member.clone();
- async move { Json(m) }
- }),
- )
- .route(
- "/guilds/{guild_id}/roles",
- get(move |Path(_g): Path| {
- let r = roles.clone();
- async move { Json(r) }
- }),
- )
- .route(
- "/channels/{channel_id}",
- get(move |Path(_c): Path| {
- let c = channel.clone();
- async move { Json(c) }
- }),
- )
-}
-
-#[tokio::test]
-async fn check_channel_permissions_administrator_bypasses_everything() {
- let member = json!({ "roles": ["role-admin"], "user": { "id": "bot-1" } });
- // Role with Administrator bit (1<<3 = 8) — overrides all other checks.
- let roles = json!([
- { "id": "role-admin", "permissions": "8" }
- ]);
- let channel = json!({ "permission_overwrites": [] });
- let base = spawn_mock(permissions_mock(member, roles, channel)).await;
- let out = check_channel_permissions_at_base(&base, "token", "guild-1", "channel-1")
- .await
- .unwrap();
- assert!(out.can_view_channel);
- assert!(out.can_send_messages);
- assert!(out.can_read_message_history);
- assert!(out.missing_permissions.is_empty());
-}
-
-#[tokio::test]
-async fn check_channel_permissions_flags_missing_bits_when_role_lacks_them() {
- // No roles grant any of the 3 permissions → all missing.
- let member = json!({ "roles": ["role-nobody"], "user": { "id": "bot-1" } });
- let roles = json!([
- { "id": "role-nobody", "permissions": "0" }
- ]);
- let channel = json!({ "permission_overwrites": [] });
- let base = spawn_mock(permissions_mock(member, roles, channel)).await;
- let out = check_channel_permissions_at_base(&base, "t", "guild-1", "channel-1")
- .await
- .unwrap();
- assert!(!out.can_view_channel);
- assert!(!out.can_send_messages);
- assert!(!out.can_read_message_history);
- assert!(out
- .missing_permissions
- .contains(&"VIEW_CHANNEL".to_string()));
- assert!(out
- .missing_permissions
- .contains(&"SEND_MESSAGES".to_string()));
- assert!(out
- .missing_permissions
- .contains(&"READ_MESSAGE_HISTORY".to_string()));
-}
-
-#[tokio::test]
-async fn check_channel_permissions_grants_everything_when_everyone_role_allows() {
- // @everyone role (id == guild_id) grants VIEW|SEND|HISTORY
- // = 1024 | 2048 | 65536 = 68608
- let member = json!({ "roles": [], "user": { "id": "bot-1" } });
- let roles = json!([
- { "id": "guild-1", "permissions": "68608" }
- ]);
- let channel = json!({ "permission_overwrites": [] });
- let base = spawn_mock(permissions_mock(member, roles, channel)).await;
- let out = check_channel_permissions_at_base(&base, "t", "guild-1", "channel-1")
- .await
- .unwrap();
- assert!(out.can_view_channel);
- assert!(out.can_send_messages);
- assert!(out.can_read_message_history);
- assert!(out.missing_permissions.is_empty());
-}
-
-#[tokio::test]
-async fn check_channel_permissions_channel_overwrite_can_deny_permission() {
- // @everyone role grants everything, but the channel's @everyone
- // overwrite denies VIEW_CHANNEL — expect VIEW missing.
- let member = json!({ "roles": [], "user": { "id": "bot-1" } });
- let roles = json!([
- { "id": "guild-1", "permissions": "68608" }
- ]);
- let channel = json!({
- "permission_overwrites": [
- {
- "id": "guild-1",
- "type": 0,
- "allow": "0",
- "deny": "1024" // VIEW_CHANNEL
- }
- ]
- });
- let base = spawn_mock(permissions_mock(member, roles, channel)).await;
- let out = check_channel_permissions_at_base(&base, "t", "guild-1", "channel-1")
- .await
- .unwrap();
- assert!(!out.can_view_channel);
- assert!(out
- .missing_permissions
- .contains(&"VIEW_CHANNEL".to_string()));
-}
-
-#[tokio::test]
-async fn check_channel_permissions_errors_on_member_lookup_failure() {
- use axum::http::StatusCode;
- let app = Router::new()
- .route(
- "/users/@me",
- get(|| async { Json(json!({ "id": "bot-1" })) }),
- )
- .route(
- "/guilds/{guild_id}/members/{member_id}",
- get(|Path((_g, _member_id)): Path<(String, String)>| async {
- (StatusCode::UNAUTHORIZED, "bad token")
- }),
- );
- let base = spawn_mock(app).await;
- let err = check_channel_permissions_at_base(&base, "t", "g", "c")
- .await
- .unwrap_err()
- .to_string();
- // Endpoint identifier preserved in the rewrap (#2285), and the
- // 401 path keeps the substrings "401"/"unauthorized" out of the
- // user-facing message so the JSON-RPC session-expired classifier
- // ignores it.
- assert!(err.contains("get_member_info"));
- assert!(
- !err.to_ascii_lowercase().contains("401"),
- "rewrapped message must not contain '401': {err}"
- );
- assert!(
- !err.to_ascii_lowercase().contains("unauthorized"),
- "rewrapped message must not contain 'unauthorized': {err}"
- );
-}
diff --git a/src/openhuman/channels/providers/discord/channel.rs b/src/openhuman/channels/providers/discord/channel.rs
deleted file mode 100644
index 73227fb55f..0000000000
--- a/src/openhuman/channels/providers/discord/channel.rs
+++ /dev/null
@@ -1,525 +0,0 @@
-use crate::openhuman::channels::traits::{Channel, ChannelMessage, SendMessage};
-use async_trait::async_trait;
-use futures_util::{SinkExt, StreamExt};
-use parking_lot::Mutex;
-use serde_json::json;
-use tinychannels::channel::LengthUnit;
-use tinychannels::text::{chunk_text_with_options, ChunkMode, TextChunkOptions};
-use tokio_tungstenite::tungstenite::Message;
-use uuid::Uuid;
-
-/// Discord channel — connects via Gateway WebSocket for real-time messages
-pub struct DiscordChannel {
- bot_token: String,
- guild_id: Option,
- channel_id: Option,
- allowed_users: Vec,
- listen_to_bots: bool,
- mention_only: bool,
- typing_handle: Mutex
\n\n\n world
"),
- "hello world"
- );
-}
diff --git a/src/openhuman/channels/providers/imessage.rs b/src/openhuman/channels/providers/imessage.rs
index b9ae06f58f..50d431139c 100644
--- a/src/openhuman/channels/providers/imessage.rs
+++ b/src/openhuman/channels/providers/imessage.rs
@@ -1,318 +1 @@
-use crate::openhuman::channels::traits::{Channel, ChannelMessage, SendMessage};
-use async_trait::async_trait;
-use directories::UserDirs;
-use rusqlite::{Connection, OpenFlags};
-use std::path::Path;
-use tokio::sync::mpsc;
-
-/// iMessage channel using macOS `AppleScript` bridge.
-/// Polls the Messages database for new messages and sends replies via `osascript`.
-#[derive(Clone)]
-pub struct IMessageChannel {
- allowed_contacts: Vec,
- poll_interval_secs: u64,
-}
-
-impl IMessageChannel {
- pub fn new(allowed_contacts: Vec) -> Self {
- Self {
- allowed_contacts,
- poll_interval_secs: 3,
- }
- }
-
- fn is_contact_allowed(&self, sender: &str) -> bool {
- if self.allowed_contacts.iter().any(|u| u == "*") {
- return true;
- }
- self.allowed_contacts
- .iter()
- .any(|u| u.eq_ignore_ascii_case(sender))
- }
-}
-
-/// Escape a string for safe interpolation into `AppleScript`.
-///
-/// This prevents injection attacks by escaping:
-/// - Backslashes (`\` → `\\`)
-/// - Double quotes (`"` → `\"`)
-/// - Newlines (`\n` → `\\n`, `\r` → `\\r`) to prevent code injection via line breaks
-fn escape_applescript(s: &str) -> String {
- s.replace('\\', "\\\\")
- .replace('"', "\\\"")
- .replace('\n', "\\n")
- .replace('\r', "\\r")
-}
-
-/// Validate that a target looks like a valid phone number or email address.
-///
-/// This is a defense-in-depth measure to reject obviously malicious targets
-/// before they reach `AppleScript` interpolation.
-///
-/// Valid patterns:
-/// - Phone: starts with `+` followed by digits (with optional spaces/dashes)
-/// - Email: contains `@` with alphanumeric chars on both sides
-fn is_valid_imessage_target(target: &str) -> bool {
- let target = target.trim();
- if target.is_empty() {
- return false;
- }
-
- // Phone number: +1234567890 or +1 234-567-8900
- if target.starts_with('+') {
- let digits_only: String = target.chars().filter(char::is_ascii_digit).collect();
- // Must have at least 7 digits (shortest valid phone numbers)
- return digits_only.len() >= 7 && digits_only.len() <= 15;
- }
-
- // Email: simple validation (contains @ with chars on both sides)
- if let Some(at_pos) = target.find('@') {
- let local = &target[..at_pos];
- let domain = &target[at_pos + 1..];
-
- // Local part: non-empty, alphanumeric + common email chars
- let local_valid = !local.is_empty()
- && local
- .chars()
- .all(|c| c.is_alphanumeric() || "._+-".contains(c));
-
- // Domain: non-empty, contains a dot, alphanumeric + dots/hyphens
- let domain_valid = !domain.is_empty()
- && domain.contains('.')
- && domain
- .chars()
- .all(|c| c.is_alphanumeric() || ".-".contains(c));
-
- return local_valid && domain_valid;
- }
-
- false
-}
-
-#[async_trait]
-impl Channel for IMessageChannel {
- fn name(&self) -> &str {
- "imessage"
- }
-
- async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
- // Defense-in-depth: validate target format before any interpolation
- if !is_valid_imessage_target(&message.recipient) {
- anyhow::bail!(
- "Invalid iMessage target: must be a phone number (+1234567890) or email (user@example.com)"
- );
- }
-
- // SECURITY: Escape both message AND target to prevent AppleScript injection
- // See: CWE-78 (OS Command Injection)
- let escaped_msg = escape_applescript(&message.content);
- let escaped_target = escape_applescript(&message.recipient);
-
- let script = format!(
- r#"tell application "Messages"
- set targetService to 1st account whose service type = iMessage
- set targetBuddy to participant "{escaped_target}" of targetService
- send "{escaped_msg}" to targetBuddy
-end tell"#
- );
-
- let output = tokio::process::Command::new("osascript")
- .arg("-e")
- .arg(&script)
- .output()
- .await?;
-
- if !output.status.success() {
- let stderr = String::from_utf8_lossy(&output.stderr);
- anyhow::bail!("iMessage send failed: {stderr}");
- }
-
- Ok(())
- }
-
- async fn listen(&self, tx: mpsc::Sender) -> anyhow::Result<()> {
- tracing::info!("iMessage channel listening (AppleScript bridge)...");
-
- // Query the Messages SQLite database for new messages
- // The database is at ~/Library/Messages/chat.db
- let db_path = UserDirs::new()
- .map(|u| u.home_dir().join("Library/Messages/chat.db"))
- .ok_or_else(|| anyhow::anyhow!("Cannot find home directory"))?;
-
- if !db_path.exists() {
- anyhow::bail!(
- "Messages database not found at {}. Ensure Messages.app is set up and Full Disk Access is granted.",
- db_path.display()
- );
- }
-
- // Open a persistent read-only connection instead of creating
- // a new one on every 3-second poll cycle.
- let path = db_path.to_path_buf();
- let conn = tokio::task::spawn_blocking(move || -> anyhow::Result {
- Ok(Connection::open_with_flags(
- &path,
- OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
- )?)
- })
- .await??;
-
- // Track the last ROWID we've seen (shuttle conn in and out)
- let (mut conn, initial_rowid) =
- tokio::task::spawn_blocking(move || -> anyhow::Result<(Connection, i64)> {
- let rowid = {
- let mut stmt =
- conn.prepare("SELECT MAX(ROWID) FROM message WHERE is_from_me = 0")?;
- let rowid: Option = stmt.query_row([], |row| row.get(0))?;
- rowid.unwrap_or(0)
- };
- Ok((conn, rowid))
- })
- .await??;
- let mut last_rowid = initial_rowid;
-
- loop {
- tokio::time::sleep(tokio::time::Duration::from_secs(self.poll_interval_secs)).await;
-
- let since = last_rowid;
- let (returned_conn, poll_result) = tokio::task::spawn_blocking(
- move || -> (Connection, anyhow::Result>) {
- let result = (|| -> anyhow::Result> {
- let mut stmt = conn.prepare(
- "SELECT m.ROWID, h.id, m.text \
- FROM message m \
- JOIN handle h ON m.handle_id = h.ROWID \
- WHERE m.ROWID > ?1 \
- AND m.is_from_me = 0 \
- AND m.text IS NOT NULL \
- ORDER BY m.ROWID ASC \
- LIMIT 20",
- )?;
- let rows = stmt.query_map([since], |row| {
- Ok((
- row.get::<_, i64>(0)?,
- row.get::<_, String>(1)?,
- row.get::<_, String>(2)?,
- ))
- })?;
- let results = rows.collect::, _>>()?;
- Ok(results)
- })();
-
- (conn, result)
- },
- )
- .await
- .map_err(|e| anyhow::anyhow!("iMessage poll worker join error: {e}"))?;
- conn = returned_conn;
-
- match poll_result {
- Ok(messages) => {
- for (rowid, sender, text) in messages {
- if rowid > last_rowid {
- last_rowid = rowid;
- }
-
- if !self.is_contact_allowed(&sender) {
- continue;
- }
-
- if text.trim().is_empty() {
- continue;
- }
-
- let msg = ChannelMessage {
- id: rowid.to_string(),
- sender: sender.clone(),
- reply_target: sender.clone(),
- content: text,
- channel: "imessage".to_string(),
- timestamp: std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .unwrap_or_default()
- .as_secs(),
- thread_ts: None,
- };
-
- if tx.send(msg).await.is_err() {
- return Ok(());
- }
- }
- }
- Err(e) => {
- tracing::warn!("iMessage poll error: {e}");
- }
- }
- }
- }
-
- async fn health_check(&self) -> bool {
- if std::env::consts::OS != "macos" {
- return false;
- }
-
- let db_path = UserDirs::new()
- .map(|u| u.home_dir().join("Library/Messages/chat.db"))
- .unwrap_or_default();
-
- db_path.exists()
- }
-}
-
-/// Get the current max ROWID from the messages table.
-/// Uses rusqlite with parameterized queries for security (CWE-89 prevention).
-async fn get_max_rowid(db_path: &Path) -> anyhow::Result {
- let path = db_path.to_path_buf();
- let result = tokio::task::spawn_blocking(move || -> anyhow::Result {
- let conn = Connection::open_with_flags(
- &path,
- OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
- )?;
- let mut stmt = conn.prepare("SELECT MAX(ROWID) FROM message WHERE is_from_me = 0")?;
- let rowid: Option = stmt.query_row([], |row| row.get(0))?;
- Ok(rowid.unwrap_or(0))
- })
- .await??;
- Ok(result)
-}
-
-/// Fetch messages newer than `since_rowid`.
-/// Uses rusqlite with parameterized queries for security (CWE-89 prevention).
-/// The `since_rowid` parameter is bound safely, preventing SQL injection.
-async fn fetch_new_messages(
- db_path: &Path,
- since_rowid: i64,
-) -> anyhow::Result> {
- let path = db_path.to_path_buf();
- let results =
- tokio::task::spawn_blocking(move || -> anyhow::Result> {
- let conn = Connection::open_with_flags(
- &path,
- OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
- )?;
- let mut stmt = conn.prepare(
- "SELECT m.ROWID, h.id, m.text \
- FROM message m \
- JOIN handle h ON m.handle_id = h.ROWID \
- WHERE m.ROWID > ?1 \
- AND m.is_from_me = 0 \
- AND m.text IS NOT NULL \
- ORDER BY m.ROWID ASC \
- LIMIT 20",
- )?;
- let rows = stmt.query_map([since_rowid], |row| {
- Ok((
- row.get::<_, i64>(0)?,
- row.get::<_, String>(1)?,
- row.get::<_, String>(2)?,
- ))
- })?;
- rows.collect::, _>>().map_err(Into::into)
- })
- .await??;
- Ok(results)
-}
-
-#[cfg(test)]
-#[path = "imessage_tests.rs"]
-mod tests;
+pub use tinychannels::providers::imessage::*;
diff --git a/src/openhuman/channels/providers/imessage_tests.rs b/src/openhuman/channels/providers/imessage_tests.rs
deleted file mode 100644
index 852dbb07ea..0000000000
--- a/src/openhuman/channels/providers/imessage_tests.rs
+++ /dev/null
@@ -1,672 +0,0 @@
-use super::*;
-
-#[test]
-fn creates_with_contacts() {
- let ch = IMessageChannel::new(vec!["+1234567890".into()]);
- assert_eq!(ch.allowed_contacts.len(), 1);
- assert_eq!(ch.poll_interval_secs, 3);
-}
-
-#[test]
-fn creates_with_empty_contacts() {
- let ch = IMessageChannel::new(vec![]);
- assert!(ch.allowed_contacts.is_empty());
-}
-
-#[test]
-fn wildcard_allows_anyone() {
- let ch = IMessageChannel::new(vec!["*".into()]);
- assert!(ch.is_contact_allowed("+1234567890"));
- assert!(ch.is_contact_allowed("random@icloud.com"));
- assert!(ch.is_contact_allowed(""));
-}
-
-#[test]
-fn specific_contact_allowed() {
- let ch = IMessageChannel::new(vec!["+1234567890".into(), "user@icloud.com".into()]);
- assert!(ch.is_contact_allowed("+1234567890"));
- assert!(ch.is_contact_allowed("user@icloud.com"));
-}
-
-#[test]
-fn unknown_contact_denied() {
- let ch = IMessageChannel::new(vec!["+1234567890".into()]);
- assert!(!ch.is_contact_allowed("+9999999999"));
- assert!(!ch.is_contact_allowed("hacker@evil.com"));
-}
-
-#[test]
-fn contact_case_insensitive() {
- let ch = IMessageChannel::new(vec!["User@iCloud.com".into()]);
- assert!(ch.is_contact_allowed("user@icloud.com"));
- assert!(ch.is_contact_allowed("USER@ICLOUD.COM"));
-}
-
-#[test]
-fn empty_allowlist_denies_all() {
- let ch = IMessageChannel::new(vec![]);
- assert!(!ch.is_contact_allowed("+1234567890"));
- assert!(!ch.is_contact_allowed("anyone"));
-}
-
-#[test]
-fn name_returns_imessage() {
- let ch = IMessageChannel::new(vec![]);
- assert_eq!(ch.name(), "imessage");
-}
-
-#[test]
-fn wildcard_among_others_still_allows_all() {
- let ch = IMessageChannel::new(vec!["+111".into(), "*".into(), "+222".into()]);
- assert!(ch.is_contact_allowed("totally-unknown"));
-}
-
-#[test]
-fn contact_with_spaces_exact_match() {
- let ch = IMessageChannel::new(vec![" spaced ".into()]);
- assert!(ch.is_contact_allowed(" spaced "));
- assert!(!ch.is_contact_allowed("spaced"));
-}
-
-// ══════════════════════════════════════════════════════════
-// AppleScript Escaping Tests (CWE-78 Prevention)
-// ══════════════════════════════════════════════════════════
-
-#[test]
-fn escape_applescript_double_quotes() {
- assert_eq!(escape_applescript(r#"hello "world""#), r#"hello \"world\""#);
-}
-
-#[test]
-fn escape_applescript_backslashes() {
- assert_eq!(escape_applescript(r"path\to\file"), r"path\\to\\file");
-}
-
-#[test]
-fn escape_applescript_mixed() {
- assert_eq!(
- escape_applescript(r#"say "hello\" world"#),
- r#"say \"hello\\\" world"#
- );
-}
-
-#[test]
-fn escape_applescript_injection_attempt() {
- // This is the exact attack vector from the security report
- let malicious = r#"" & do shell script "id" & ""#;
- let escaped = escape_applescript(malicious);
- // After escaping, the quotes should be escaped and not break out
- assert_eq!(escaped, r#"\" & do shell script \"id\" & \""#);
- // Verify all quotes are now escaped (preceded by backslash)
- // The escaped string should not have any unescaped quotes (quote not preceded by backslash)
- let chars: Vec = escaped.chars().collect();
- for (i, &c) in chars.iter().enumerate() {
- if c == '"' {
- // Every quote must be preceded by a backslash
- assert!(
- i > 0 && chars[i - 1] == '\\',
- "Found unescaped quote at position {i}"
- );
- }
- }
-}
-
-#[test]
-fn escape_applescript_empty_string() {
- assert_eq!(escape_applescript(""), "");
-}
-
-#[test]
-fn escape_applescript_no_special_chars() {
- assert_eq!(escape_applescript("hello world"), "hello world");
-}
-
-#[test]
-fn escape_applescript_unicode() {
- assert_eq!(escape_applescript("hello 🦀 world"), "hello 🦀 world");
-}
-
-#[test]
-fn escape_applescript_newlines_escaped() {
- assert_eq!(escape_applescript("line1\nline2"), "line1\\nline2");
- assert_eq!(escape_applescript("line1\rline2"), "line1\\rline2");
- assert_eq!(escape_applescript("line1\r\nline2"), "line1\\r\\nline2");
-}
-
-// ══════════════════════════════════════════════════════════
-// Target Validation Tests
-// ══════════════════════════════════════════════════════════
-
-#[test]
-fn valid_phone_number_simple() {
- assert!(is_valid_imessage_target("+1234567890"));
-}
-
-#[test]
-fn valid_phone_number_with_country_code() {
- assert!(is_valid_imessage_target("+14155551234"));
-}
-
-#[test]
-fn valid_phone_number_with_spaces() {
- assert!(is_valid_imessage_target("+1 415 555 1234"));
-}
-
-#[test]
-fn valid_phone_number_with_dashes() {
- assert!(is_valid_imessage_target("+1-415-555-1234"));
-}
-
-#[test]
-fn valid_phone_number_international() {
- assert!(is_valid_imessage_target("+447911123456")); // UK
- assert!(is_valid_imessage_target("+81312345678")); // Japan
-}
-
-#[test]
-fn valid_email_simple() {
- assert!(is_valid_imessage_target("user@example.com"));
-}
-
-#[test]
-fn valid_email_with_subdomain() {
- assert!(is_valid_imessage_target("user@mail.example.com"));
-}
-
-#[test]
-fn valid_email_with_plus() {
- assert!(is_valid_imessage_target("user+tag@example.com"));
-}
-
-#[test]
-fn valid_email_with_dots() {
- assert!(is_valid_imessage_target("first.last@example.com"));
-}
-
-#[test]
-fn valid_email_icloud() {
- assert!(is_valid_imessage_target("user@icloud.com"));
- assert!(is_valid_imessage_target("user@me.com"));
-}
-
-#[test]
-fn invalid_target_empty() {
- assert!(!is_valid_imessage_target(""));
- assert!(!is_valid_imessage_target(" "));
-}
-
-#[test]
-fn invalid_target_no_plus_prefix() {
- // Phone numbers must start with +
- assert!(!is_valid_imessage_target("1234567890"));
-}
-
-#[test]
-fn invalid_target_too_short_phone() {
- // Less than 7 digits
- assert!(!is_valid_imessage_target("+123456"));
-}
-
-#[test]
-fn invalid_target_too_long_phone() {
- // More than 15 digits
- assert!(!is_valid_imessage_target("+1234567890123456"));
-}
-
-#[test]
-fn invalid_target_email_no_at() {
- assert!(!is_valid_imessage_target("userexample.com"));
-}
-
-#[test]
-fn invalid_target_email_no_domain() {
- assert!(!is_valid_imessage_target("user@"));
-}
-
-#[test]
-fn invalid_target_email_no_local() {
- assert!(!is_valid_imessage_target("@example.com"));
-}
-
-#[test]
-fn invalid_target_email_no_dot_in_domain() {
- assert!(!is_valid_imessage_target("user@localhost"));
-}
-
-#[test]
-fn invalid_target_injection_attempt() {
- // The exact attack vector from the security report
- assert!(!is_valid_imessage_target(r#"" & do shell script "id" & ""#));
-}
-
-#[test]
-fn invalid_target_applescript_injection() {
- // Various injection attempts
- assert!(!is_valid_imessage_target(r#"test" & quit"#));
- assert!(!is_valid_imessage_target(r"test\ndo shell script"));
- assert!(!is_valid_imessage_target("test\"; malicious code; \""));
-}
-
-#[test]
-fn invalid_target_special_chars() {
- assert!(!is_valid_imessage_target("user