mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-31 08:39:24 +00:00
Refactor owner scope across channels and fix default routing fallback (#1151)
* refactor: add explicit owner scope across channels * fix: tighten routine owner target routing * fix: address owner scope review feedback * Fix owner-scope onboarding and event trigger isolation * Tighten routing fallback and wizard owner validation * fix: address owner-scope follow-up review * fix: tighten owner-scope follow-up details * fix: import Channel trait in telegram test * fix: normalize http webhook sender ids * fix: address remaining owner-scope review issues * fix: reconcile config rebase fallout * fix: reconcile extension manager rebase drift * fix: address current copilot review regressions * fix: restore clippy matrix after rebase
This commit is contained in:
+82
-6
@@ -67,14 +67,24 @@ pub struct IncomingMessage {
|
||||
pub id: Uuid,
|
||||
/// Channel this message came from.
|
||||
pub channel: String,
|
||||
/// User identifier within the channel.
|
||||
/// Storage/persistence scope for this interaction.
|
||||
///
|
||||
/// For owner-capable channels this is the stable instance owner ID when the
|
||||
/// configured owner is speaking; otherwise it can be a guest/sender-scoped
|
||||
/// identifier to preserve isolation.
|
||||
pub user_id: String,
|
||||
/// Stable instance owner scope for this IronClaw deployment.
|
||||
pub owner_id: String,
|
||||
/// Channel-specific sender/actor identifier.
|
||||
pub sender_id: String,
|
||||
/// Optional display name.
|
||||
pub user_name: Option<String>,
|
||||
/// Message content.
|
||||
pub content: String,
|
||||
/// Thread/conversation ID for threaded conversations.
|
||||
pub thread_id: Option<String>,
|
||||
/// Stable channel/chat/thread scope for this conversation.
|
||||
pub conversation_scope_id: Option<String>,
|
||||
/// When the message was received.
|
||||
pub received_at: DateTime<Utc>,
|
||||
/// Channel-specific metadata.
|
||||
@@ -84,9 +94,8 @@ pub struct IncomingMessage {
|
||||
/// File or media attachments on this message.
|
||||
pub attachments: Vec<IncomingAttachment>,
|
||||
/// Internal-only flag: message was generated inside the process (e.g. job
|
||||
/// monitor) and must bypass the normal user-input pipeline. This field is
|
||||
/// **not** settable via `with_metadata()` — only trusted code paths inside
|
||||
/// the binary can set it, preventing external channels from spoofing it.
|
||||
/// monitor) and must bypass the normal user-input pipeline. This field is
|
||||
/// not settable via metadata, so external channels cannot spoof it.
|
||||
pub(crate) is_internal: bool,
|
||||
}
|
||||
|
||||
@@ -97,13 +106,17 @@ impl IncomingMessage {
|
||||
user_id: impl Into<String>,
|
||||
content: impl Into<String>,
|
||||
) -> Self {
|
||||
let user_id = user_id.into();
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
channel: channel.into(),
|
||||
user_id: user_id.into(),
|
||||
owner_id: user_id.clone(),
|
||||
sender_id: user_id.clone(),
|
||||
user_id,
|
||||
user_name: None,
|
||||
content: content.into(),
|
||||
thread_id: None,
|
||||
conversation_scope_id: None,
|
||||
received_at: Utc::now(),
|
||||
metadata: serde_json::Value::Null,
|
||||
timezone: None,
|
||||
@@ -114,7 +127,27 @@ impl IncomingMessage {
|
||||
|
||||
/// Set the thread ID.
|
||||
pub fn with_thread(mut self, thread_id: impl Into<String>) -> Self {
|
||||
self.thread_id = Some(thread_id.into());
|
||||
let thread_id = thread_id.into();
|
||||
self.conversation_scope_id = Some(thread_id.clone());
|
||||
self.thread_id = Some(thread_id);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the stable owner scope for this message.
|
||||
pub fn with_owner_id(mut self, owner_id: impl Into<String>) -> Self {
|
||||
self.owner_id = owner_id.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the channel-specific sender/actor identifier.
|
||||
pub fn with_sender_id(mut self, sender_id: impl Into<String>) -> Self {
|
||||
self.sender_id = sender_id.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the conversation scope for this message.
|
||||
pub fn with_conversation_scope(mut self, scope_id: impl Into<String>) -> Self {
|
||||
self.conversation_scope_id = Some(scope_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
@@ -147,6 +180,49 @@ impl IncomingMessage {
|
||||
self.is_internal = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Effective conversation scope, falling back to thread_id for legacy callers.
|
||||
pub fn conversation_scope(&self) -> Option<&str> {
|
||||
self.conversation_scope_id
|
||||
.as_deref()
|
||||
.or(self.thread_id.as_deref())
|
||||
}
|
||||
|
||||
/// Best-effort routing target for proactive replies on the current channel.
|
||||
pub fn routing_target(&self) -> Option<String> {
|
||||
routing_target_from_metadata(&self.metadata).or_else(|| {
|
||||
if self.sender_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.sender_id.clone())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a channel-specific proactive routing target from message metadata.
|
||||
pub fn routing_target_from_metadata(metadata: &serde_json::Value) -> Option<String> {
|
||||
metadata
|
||||
.get("signal_target")
|
||||
.and_then(|value| match value {
|
||||
serde_json::Value::String(s) => Some(s.clone()),
|
||||
serde_json::Value::Number(n) => Some(n.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.or_else(|| {
|
||||
metadata.get("chat_id").and_then(|value| match value {
|
||||
serde_json::Value::String(s) => Some(s.clone()),
|
||||
serde_json::Value::Number(n) => Some(n.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
metadata.get("target").and_then(|value| match value {
|
||||
serde_json::Value::String(s) => Some(s.clone()),
|
||||
serde_json::Value::Number(n) => Some(n.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Stream of incoming messages.
|
||||
|
||||
+105
-11
@@ -133,7 +133,8 @@ impl HttpChannel {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WebhookRequest {
|
||||
/// User or client identifier (ignored, user is fixed by server config).
|
||||
/// Optional caller or client identifier for sender-scoped routing.
|
||||
/// The channel owner/storage scope remains fixed by server config.
|
||||
#[serde(default)]
|
||||
user_id: Option<String>,
|
||||
/// Message content.
|
||||
@@ -403,12 +404,38 @@ async fn process_authenticated_request(
|
||||
state: Arc<HttpChannelState>,
|
||||
req: WebhookRequest,
|
||||
) -> axum::response::Response {
|
||||
let _ = req.user_id.as_ref().map(|user_id| {
|
||||
tracing::debug!(
|
||||
provided_user_id = %user_id,
|
||||
"HTTP webhook request provided user_id, ignoring in favor of configured user_id"
|
||||
);
|
||||
});
|
||||
let normalized_user_id = req
|
||||
.user_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|user_id| !user_id.is_empty());
|
||||
|
||||
match (req.user_id.as_deref(), normalized_user_id) {
|
||||
(Some(raw_user_id), Some(user_id)) if raw_user_id != user_id => {
|
||||
tracing::debug!(
|
||||
provided_user_id = %raw_user_id,
|
||||
normalized_sender_id = %user_id,
|
||||
configured_owner_id = %state.user_id,
|
||||
"HTTP webhook request provided user_id; trimming and using it as sender_id while keeping the configured owner scope"
|
||||
);
|
||||
}
|
||||
(Some(user_id), Some(_)) => {
|
||||
tracing::debug!(
|
||||
provided_user_id = %user_id,
|
||||
configured_owner_id = %state.user_id,
|
||||
"HTTP webhook request provided user_id; using it as sender_id while keeping the configured owner scope"
|
||||
);
|
||||
}
|
||||
(Some(raw_user_id), None) => {
|
||||
tracing::debug!(
|
||||
provided_user_id = %raw_user_id,
|
||||
configured_owner_id = %state.user_id,
|
||||
"HTTP webhook request provided a blank user_id; falling back to the configured owner scope for sender_id"
|
||||
);
|
||||
}
|
||||
(None, None) => {}
|
||||
(None, Some(_)) => unreachable!("normalized user_id requires a raw user_id"),
|
||||
}
|
||||
|
||||
if req.content.len() > MAX_CONTENT_BYTES {
|
||||
return (
|
||||
@@ -514,11 +541,13 @@ async fn process_authenticated_request(
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let mut msg = IncomingMessage::new("http", &state.user_id, &req.content).with_metadata(
|
||||
serde_json::json!({
|
||||
let sender_id = normalized_user_id.unwrap_or(&state.user_id).to_string();
|
||||
let mut msg = IncomingMessage::new("http", &state.user_id, &req.content)
|
||||
.with_owner_id(&state.user_id)
|
||||
.with_sender_id(sender_id)
|
||||
.with_metadata(serde_json::json!({
|
||||
"wait_for_response": wait_for_response,
|
||||
}),
|
||||
);
|
||||
}));
|
||||
|
||||
if !attachments.is_empty() {
|
||||
msg = msg.with_attachments(attachments);
|
||||
@@ -682,6 +711,7 @@ mod tests {
|
||||
use axum::body::Body;
|
||||
use axum::http::{HeaderValue, Request};
|
||||
use secrecy::SecretString;
|
||||
use tokio_stream::StreamExt;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use super::*;
|
||||
@@ -820,6 +850,70 @@ mod tests {
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn webhook_blank_user_id_falls_back_to_owner_scope() {
|
||||
let secret = "test-secret-123";
|
||||
let channel = test_channel(Some(secret));
|
||||
let mut stream = channel.start().await.unwrap();
|
||||
let app = channel.routes();
|
||||
|
||||
let body = serde_json::json!({
|
||||
"content": "hello",
|
||||
"user_id": " "
|
||||
});
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap();
|
||||
let signature = compute_signature(secret, &body_bytes);
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-hub-signature-256", signature)
|
||||
.body(Body::from(body_bytes))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let msg = tokio::time::timeout(std::time::Duration::from_secs(1), stream.next())
|
||||
.await
|
||||
.expect("timed out waiting for webhook message")
|
||||
.expect("stream should yield a webhook message");
|
||||
assert_eq!(msg.sender_id, "http");
|
||||
assert_eq!(msg.owner_id, "http");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn webhook_user_id_is_trimmed_before_becoming_sender_id() {
|
||||
let secret = "test-secret-123";
|
||||
let channel = test_channel(Some(secret));
|
||||
let mut stream = channel.start().await.unwrap();
|
||||
let app = channel.routes();
|
||||
|
||||
let body = serde_json::json!({
|
||||
"content": "hello",
|
||||
"user_id": " alice "
|
||||
});
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap();
|
||||
let signature = compute_signature(secret, &body_bytes);
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-hub-signature-256", signature)
|
||||
.body(Body::from(body_bytes))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let msg = tokio::time::timeout(std::time::Duration::from_secs(1), stream.next())
|
||||
.await
|
||||
.expect("timed out waiting for webhook message")
|
||||
.expect("stream should yield a webhook message");
|
||||
assert_eq!(msg.sender_id, "alice");
|
||||
assert_eq!(msg.owner_id, "http");
|
||||
}
|
||||
|
||||
/// Regression test for issue #869: RwLock read guard was held across
|
||||
/// tx.send(msg).await in `process_message()`, blocking shutdown() from
|
||||
/// acquiring the write lock when the channel buffer was full.
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ mod webhook_server;
|
||||
|
||||
pub use channel::{
|
||||
AttachmentKind, Channel, ChannelSecretUpdater, IncomingAttachment, IncomingMessage,
|
||||
MessageStream, OutgoingResponse, StatusUpdate,
|
||||
MessageStream, OutgoingResponse, StatusUpdate, routing_target_from_metadata,
|
||||
};
|
||||
pub use http::{HttpChannel, HttpChannelState};
|
||||
pub use manager::ChannelManager;
|
||||
|
||||
+22
-7
@@ -200,6 +200,8 @@ fn format_json_params(params: &serde_json::Value, indent: &str) -> String {
|
||||
|
||||
/// REPL channel with line editing and markdown rendering.
|
||||
pub struct ReplChannel {
|
||||
/// Stable owner scope for this REPL instance.
|
||||
user_id: String,
|
||||
/// Optional single message to send (for -m flag).
|
||||
single_message: Option<String>,
|
||||
/// Debug mode flag (shared with input thread).
|
||||
@@ -213,7 +215,13 @@ pub struct ReplChannel {
|
||||
impl ReplChannel {
|
||||
/// Create a new REPL channel.
|
||||
pub fn new() -> Self {
|
||||
Self::with_user_id("default")
|
||||
}
|
||||
|
||||
/// Create a new REPL channel for a specific owner scope.
|
||||
pub fn with_user_id(user_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
user_id: user_id.into(),
|
||||
single_message: None,
|
||||
debug_mode: Arc::new(AtomicBool::new(false)),
|
||||
is_streaming: Arc::new(AtomicBool::new(false)),
|
||||
@@ -223,7 +231,13 @@ impl ReplChannel {
|
||||
|
||||
/// Create a REPL channel that sends a single message and exits.
|
||||
pub fn with_message(message: String) -> Self {
|
||||
Self::with_message_for_user("default", message)
|
||||
}
|
||||
|
||||
/// Create a REPL channel that sends a single message for a specific owner scope and exits.
|
||||
pub fn with_message_for_user(user_id: impl Into<String>, message: String) -> Self {
|
||||
Self {
|
||||
user_id: user_id.into(),
|
||||
single_message: Some(message),
|
||||
debug_mode: Arc::new(AtomicBool::new(false)),
|
||||
is_streaming: Arc::new(AtomicBool::new(false)),
|
||||
@@ -292,6 +306,7 @@ impl Channel for ReplChannel {
|
||||
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
||||
let (tx, rx) = mpsc::channel(32);
|
||||
let single_message = self.single_message.clone();
|
||||
let user_id = self.user_id.clone();
|
||||
let debug_mode = Arc::clone(&self.debug_mode);
|
||||
let suppress_banner = Arc::clone(&self.suppress_banner);
|
||||
let esc_interrupt_triggered_for_thread = Arc::new(AtomicBool::new(false));
|
||||
@@ -301,11 +316,11 @@ impl Channel for ReplChannel {
|
||||
|
||||
// Single message mode: send it and return
|
||||
if let Some(msg) = single_message {
|
||||
let incoming = IncomingMessage::new("repl", "default", &msg).with_timezone(&sys_tz);
|
||||
let incoming = IncomingMessage::new("repl", &user_id, &msg).with_timezone(&sys_tz);
|
||||
let _ = tx.blocking_send(incoming);
|
||||
// Ensure the agent exits after handling exactly one turn in -m mode,
|
||||
// even when other channels (gateway/http) are enabled.
|
||||
let _ = tx.blocking_send(IncomingMessage::new("repl", "default", "/quit"));
|
||||
let _ = tx.blocking_send(IncomingMessage::new("repl", &user_id, "/quit"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -366,7 +381,7 @@ impl Channel for ReplChannel {
|
||||
"/quit" | "/exit" => {
|
||||
// Forward shutdown command so the agent loop exits even
|
||||
// when other channels (e.g. web gateway) are still active.
|
||||
let msg = IncomingMessage::new("repl", "default", "/quit")
|
||||
let msg = IncomingMessage::new("repl", &user_id, "/quit")
|
||||
.with_timezone(&sys_tz);
|
||||
let _ = tx.blocking_send(msg);
|
||||
break;
|
||||
@@ -389,7 +404,7 @@ impl Channel for ReplChannel {
|
||||
}
|
||||
|
||||
let msg =
|
||||
IncomingMessage::new("repl", "default", line).with_timezone(&sys_tz);
|
||||
IncomingMessage::new("repl", &user_id, line).with_timezone(&sys_tz);
|
||||
if tx.blocking_send(msg).is_err() {
|
||||
break;
|
||||
}
|
||||
@@ -397,14 +412,14 @@ impl Channel for ReplChannel {
|
||||
Err(ReadlineError::Interrupted) => {
|
||||
if esc_interrupt_triggered_for_thread.swap(false, Ordering::Relaxed) {
|
||||
// Esc: interrupt current operation and keep REPL open.
|
||||
let msg = IncomingMessage::new("repl", "default", "/interrupt")
|
||||
let msg = IncomingMessage::new("repl", &user_id, "/interrupt")
|
||||
.with_timezone(&sys_tz);
|
||||
if tx.blocking_send(msg).is_err() {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Ctrl+C (VINTR): request graceful shutdown.
|
||||
let msg = IncomingMessage::new("repl", "default", "/quit")
|
||||
let msg = IncomingMessage::new("repl", &user_id, "/quit")
|
||||
.with_timezone(&sys_tz);
|
||||
let _ = tx.blocking_send(msg);
|
||||
break;
|
||||
@@ -416,7 +431,7 @@ impl Channel for ReplChannel {
|
||||
// immediately — just drop the REPL thread silently so other
|
||||
// channels (gateway, telegram, …) keep running.
|
||||
if std::io::stdin().is_terminal() {
|
||||
let msg = IncomingMessage::new("repl", "default", "/quit")
|
||||
let msg = IncomingMessage::new("repl", &user_id, "/quit")
|
||||
.with_timezone(&sys_tz);
|
||||
let _ = tx.blocking_send(msg);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ pub struct WasmChannelLoader {
|
||||
pairing_store: Arc<PairingStore>,
|
||||
settings_store: Option<Arc<dyn SettingsStore>>,
|
||||
secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
|
||||
owner_scope_id: String,
|
||||
}
|
||||
|
||||
impl WasmChannelLoader {
|
||||
@@ -35,12 +36,14 @@ impl WasmChannelLoader {
|
||||
runtime: Arc<WasmChannelRuntime>,
|
||||
pairing_store: Arc<PairingStore>,
|
||||
settings_store: Option<Arc<dyn SettingsStore>>,
|
||||
owner_scope_id: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
runtime,
|
||||
pairing_store,
|
||||
settings_store,
|
||||
secrets_store: None,
|
||||
owner_scope_id: owner_scope_id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +152,7 @@ impl WasmChannelLoader {
|
||||
self.runtime.clone(),
|
||||
prepared,
|
||||
capabilities,
|
||||
self.owner_scope_id.clone(),
|
||||
config_json,
|
||||
self.pairing_store.clone(),
|
||||
self.settings_store.clone(),
|
||||
@@ -487,7 +491,8 @@ mod tests {
|
||||
async fn test_loader_invalid_name() {
|
||||
let config = WasmChannelRuntimeConfig::for_testing();
|
||||
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
|
||||
let loader = WasmChannelLoader::new(runtime, Arc::new(PairingStore::new()), None);
|
||||
let loader =
|
||||
WasmChannelLoader::new(runtime, Arc::new(PairingStore::new()), None, "default");
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let wasm_path = dir.path().join("test.wasm");
|
||||
@@ -505,7 +510,8 @@ mod tests {
|
||||
async fn load_from_dir_returns_empty_when_dir_missing() {
|
||||
let config = WasmChannelRuntimeConfig::for_testing();
|
||||
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
|
||||
let loader = WasmChannelLoader::new(runtime, Arc::new(PairingStore::new()), None);
|
||||
let loader =
|
||||
WasmChannelLoader::new(runtime, Arc::new(PairingStore::new()), None, "default");
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let missing = dir.path().join("nonexistent_channels_dir");
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
//! let runtime = WasmChannelRuntime::new(config)?;
|
||||
//!
|
||||
//! // Load channels from directory
|
||||
//! let loader = WasmChannelLoader::new(runtime);
|
||||
//! let loader = WasmChannelLoader::new(runtime, pairing_store, settings_store, owner_scope_id);
|
||||
//! let channels = loader.load_from_dir(Path::new("~/.ironclaw/channels/")).await?;
|
||||
//!
|
||||
//! // Add to channel manager
|
||||
|
||||
@@ -672,6 +672,7 @@ mod tests {
|
||||
runtime,
|
||||
prepared,
|
||||
capabilities,
|
||||
"default",
|
||||
"{}".to_string(),
|
||||
Arc::new(PairingStore::new()),
|
||||
None,
|
||||
|
||||
@@ -50,6 +50,7 @@ pub async fn setup_wasm_channels(
|
||||
Arc::clone(&runtime),
|
||||
Arc::clone(&pairing_store),
|
||||
settings_store.clone(),
|
||||
config.owner_id.clone(),
|
||||
);
|
||||
if let Some(secrets) = secrets_store {
|
||||
loader = loader.with_secrets_store(Arc::clone(secrets));
|
||||
@@ -117,6 +118,11 @@ async fn register_channel(
|
||||
) -> (String, Box<dyn crate::channels::Channel>) {
|
||||
let channel_name = loaded.name().to_string();
|
||||
tracing::info!("Loaded WASM channel: {}", channel_name);
|
||||
let owner_actor_id = config
|
||||
.channels
|
||||
.wasm_channel_owner_ids
|
||||
.get(channel_name.as_str())
|
||||
.map(ToString::to_string);
|
||||
|
||||
let secret_name = loaded.webhook_secret_name();
|
||||
let sig_key_secret_name = loaded.signature_key_secret_name();
|
||||
@@ -124,7 +130,7 @@ async fn register_channel(
|
||||
|
||||
let webhook_secret = if let Some(secrets) = secrets_store {
|
||||
secrets
|
||||
.get_decrypted("default", &secret_name)
|
||||
.get_decrypted(&config.owner_id, &secret_name)
|
||||
.await
|
||||
.ok()
|
||||
.map(|s| s.expose().to_string())
|
||||
@@ -142,7 +148,7 @@ async fn register_channel(
|
||||
require_secret: webhook_secret.is_some(),
|
||||
}];
|
||||
|
||||
let channel_arc = Arc::new(loaded.channel);
|
||||
let channel_arc = Arc::new(loaded.channel.with_owner_actor_id(owner_actor_id.clone()));
|
||||
|
||||
// Inject runtime config (tunnel URL, webhook secret, owner_id).
|
||||
{
|
||||
@@ -216,7 +222,7 @@ async fn register_channel(
|
||||
// Register Ed25519 signature key if declared in capabilities.
|
||||
if let Some(ref sig_key_name) = sig_key_secret_name
|
||||
&& let Some(secrets) = secrets_store
|
||||
&& let Ok(key_secret) = secrets.get_decrypted("default", sig_key_name).await
|
||||
&& let Ok(key_secret) = secrets.get_decrypted(&config.owner_id, sig_key_name).await
|
||||
{
|
||||
match wasm_router
|
||||
.register_signature_key(&channel_name, key_secret.expose())
|
||||
@@ -234,7 +240,9 @@ async fn register_channel(
|
||||
// Register HMAC signing secret if declared in capabilities.
|
||||
if let Some(ref hmac_secret_name) = hmac_secret_name
|
||||
&& let Some(secrets) = secrets_store
|
||||
&& let Ok(secret) = secrets.get_decrypted("default", hmac_secret_name).await
|
||||
&& let Ok(secret) = secrets
|
||||
.get_decrypted(&config.owner_id, hmac_secret_name)
|
||||
.await
|
||||
{
|
||||
wasm_router
|
||||
.register_hmac_secret(&channel_name, secret.expose())
|
||||
@@ -249,6 +257,7 @@ async fn register_channel(
|
||||
.as_ref()
|
||||
.map(|s| s.as_ref() as &dyn SecretsStore),
|
||||
&channel_name,
|
||||
&config.owner_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -286,6 +295,7 @@ pub async fn inject_channel_credentials(
|
||||
channel: &Arc<WasmChannel>,
|
||||
secrets: Option<&dyn SecretsStore>,
|
||||
channel_name: &str,
|
||||
owner_id: &str,
|
||||
) -> anyhow::Result<usize> {
|
||||
if channel_name.trim().is_empty() {
|
||||
return Ok(0);
|
||||
@@ -297,7 +307,7 @@ pub async fn inject_channel_credentials(
|
||||
// 1. Try injecting from persistent secrets store if available
|
||||
if let Some(secrets) = secrets {
|
||||
let all_secrets = secrets
|
||||
.list("default")
|
||||
.list(owner_id)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to list secrets: {}", e))?;
|
||||
|
||||
@@ -308,7 +318,7 @@ pub async fn inject_channel_credentials(
|
||||
continue;
|
||||
}
|
||||
|
||||
let decrypted = match secrets.get_decrypted("default", &secret_meta.name).await {
|
||||
let decrypted = match secrets.get_decrypted(owner_id, &secret_meta.name).await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
|
||||
+422
-103
@@ -709,6 +709,12 @@ pub struct WasmChannel {
|
||||
/// Settings store for persisting broadcast metadata across restarts.
|
||||
settings_store: Option<Arc<dyn crate::db::SettingsStore>>,
|
||||
|
||||
/// Stable owner scope for persistent data and owner-target routing.
|
||||
owner_scope_id: String,
|
||||
|
||||
/// Channel-specific actor ID that maps to the instance owner on this channel.
|
||||
owner_actor_id: Option<String>,
|
||||
|
||||
/// Secrets store for host-based credential injection.
|
||||
/// Used to pre-resolve credentials before each WASM callback.
|
||||
secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
|
||||
@@ -719,6 +725,7 @@ pub struct WasmChannel {
|
||||
/// method and the static polling helper share one implementation.
|
||||
async fn do_update_broadcast_metadata(
|
||||
channel_name: &str,
|
||||
owner_scope_id: &str,
|
||||
metadata: &str,
|
||||
last_broadcast_metadata: &tokio::sync::RwLock<Option<String>>,
|
||||
settings_store: Option<&Arc<dyn crate::db::SettingsStore>>,
|
||||
@@ -731,7 +738,7 @@ async fn do_update_broadcast_metadata(
|
||||
if changed && let Some(store) = settings_store {
|
||||
let key = format!("channel_broadcast_metadata_{}", channel_name);
|
||||
let value = serde_json::Value::String(metadata.to_string());
|
||||
if let Err(e) = store.set_setting("default", &key, &value).await {
|
||||
if let Err(e) = store.set_setting(owner_scope_id, &key, &value).await {
|
||||
tracing::warn!(
|
||||
channel = %channel_name,
|
||||
"Failed to persist broadcast metadata: {}",
|
||||
@@ -741,12 +748,70 @@ async fn do_update_broadcast_metadata(
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_message_scope(
|
||||
owner_scope_id: &str,
|
||||
owner_actor_id: Option<&str>,
|
||||
sender_id: &str,
|
||||
) -> (String, bool) {
|
||||
if owner_actor_id.is_some_and(|owner_actor_id| owner_actor_id == sender_id) {
|
||||
(owner_scope_id.to_string(), true)
|
||||
} else {
|
||||
(sender_id.to_string(), false)
|
||||
}
|
||||
}
|
||||
|
||||
fn uses_owner_broadcast_target(user_id: &str, owner_scope_id: &str) -> bool {
|
||||
user_id == owner_scope_id
|
||||
}
|
||||
|
||||
fn missing_routing_target_error(name: &str, reason: String) -> ChannelError {
|
||||
ChannelError::MissingRoutingTarget {
|
||||
name: name.to_string(),
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_owner_broadcast_target(
|
||||
channel_name: &str,
|
||||
metadata: &str,
|
||||
) -> Result<String, ChannelError> {
|
||||
let metadata: serde_json::Value = serde_json::from_str(metadata).map_err(|e| {
|
||||
missing_routing_target_error(
|
||||
channel_name,
|
||||
format!("Invalid stored owner routing metadata: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
crate::channels::routing_target_from_metadata(&metadata).ok_or_else(|| {
|
||||
missing_routing_target_error(
|
||||
channel_name,
|
||||
format!(
|
||||
"Stored owner routing metadata for channel '{}' is missing a delivery target.",
|
||||
channel_name
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_emitted_metadata(mut msg: IncomingMessage, metadata_json: &str) -> IncomingMessage {
|
||||
if let Ok(metadata) = serde_json::from_str(metadata_json) {
|
||||
msg = msg.with_metadata(metadata);
|
||||
if msg.conversation_scope().is_none()
|
||||
&& let Some(scope_id) = crate::channels::routing_target_from_metadata(&msg.metadata)
|
||||
{
|
||||
msg = msg.with_conversation_scope(scope_id);
|
||||
}
|
||||
}
|
||||
msg
|
||||
}
|
||||
|
||||
impl WasmChannel {
|
||||
/// Create a new WASM channel.
|
||||
pub fn new(
|
||||
runtime: Arc<WasmChannelRuntime>,
|
||||
prepared: Arc<PreparedChannelModule>,
|
||||
capabilities: ChannelCapabilities,
|
||||
owner_scope_id: impl Into<String>,
|
||||
config_json: String,
|
||||
pairing_store: Arc<PairingStore>,
|
||||
settings_store: Option<Arc<dyn crate::db::SettingsStore>>,
|
||||
@@ -773,6 +838,8 @@ impl WasmChannel {
|
||||
workspace_store: Arc::new(ChannelWorkspaceStore::new()),
|
||||
last_broadcast_metadata: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
settings_store,
|
||||
owner_scope_id: owner_scope_id.into(),
|
||||
owner_actor_id: None,
|
||||
secrets_store: None,
|
||||
}
|
||||
}
|
||||
@@ -787,6 +854,12 @@ impl WasmChannel {
|
||||
self
|
||||
}
|
||||
|
||||
/// Bind this channel to the external actor that maps to the configured owner.
|
||||
pub fn with_owner_actor_id(mut self, owner_actor_id: Option<String>) -> Self {
|
||||
self.owner_actor_id = owner_actor_id;
|
||||
self
|
||||
}
|
||||
|
||||
/// Update the channel config before starting.
|
||||
///
|
||||
/// Merges the provided values into the existing config JSON.
|
||||
@@ -843,6 +916,7 @@ impl WasmChannel {
|
||||
async fn update_broadcast_metadata(&self, metadata: &str) {
|
||||
do_update_broadcast_metadata(
|
||||
&self.name,
|
||||
&self.owner_scope_id,
|
||||
metadata,
|
||||
&self.last_broadcast_metadata,
|
||||
self.settings_store.as_ref(),
|
||||
@@ -854,7 +928,7 @@ impl WasmChannel {
|
||||
async fn load_broadcast_metadata(&self) {
|
||||
if let Some(ref store) = self.settings_store {
|
||||
match store
|
||||
.get_setting("default", &self.broadcast_metadata_key())
|
||||
.get_setting(&self.owner_scope_id, &self.broadcast_metadata_key())
|
||||
.await
|
||||
{
|
||||
Ok(Some(serde_json::Value::String(meta))) => {
|
||||
@@ -864,7 +938,30 @@ impl WasmChannel {
|
||||
"Restored broadcast metadata from settings"
|
||||
);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Ok(_) => {
|
||||
if self.owner_scope_id != "default" {
|
||||
match store
|
||||
.get_setting("default", &self.broadcast_metadata_key())
|
||||
.await
|
||||
{
|
||||
Ok(Some(serde_json::Value::String(meta))) => {
|
||||
*self.last_broadcast_metadata.write().await = Some(meta);
|
||||
tracing::debug!(
|
||||
channel = %self.name,
|
||||
"Restored legacy owner broadcast metadata from default scope"
|
||||
);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
channel = %self.name,
|
||||
"Failed to load legacy broadcast metadata: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
channel = %self.name,
|
||||
@@ -1064,9 +1161,12 @@ impl WasmChannel {
|
||||
let timeout = self.runtime.config().callback_timeout;
|
||||
let channel_name = self.name.clone();
|
||||
let credentials = self.get_credentials().await;
|
||||
let host_credentials =
|
||||
resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref())
|
||||
.await;
|
||||
let host_credentials = resolve_channel_host_credentials(
|
||||
&self.capabilities,
|
||||
self.secrets_store.as_deref(),
|
||||
&self.owner_scope_id,
|
||||
)
|
||||
.await;
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
let workspace_store = self.workspace_store.clone();
|
||||
|
||||
@@ -1204,9 +1304,12 @@ impl WasmChannel {
|
||||
let capabilities = Self::inject_workspace_reader(&self.capabilities, &self.workspace_store);
|
||||
let timeout = self.runtime.config().callback_timeout;
|
||||
let credentials = self.get_credentials().await;
|
||||
let host_credentials =
|
||||
resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref())
|
||||
.await;
|
||||
let host_credentials = resolve_channel_host_credentials(
|
||||
&self.capabilities,
|
||||
self.secrets_store.as_deref(),
|
||||
&self.owner_scope_id,
|
||||
)
|
||||
.await;
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
let workspace_store = self.workspace_store.clone();
|
||||
|
||||
@@ -1307,9 +1410,12 @@ impl WasmChannel {
|
||||
let timeout = self.runtime.config().callback_timeout;
|
||||
let channel_name = self.name.clone();
|
||||
let credentials = self.get_credentials().await;
|
||||
let host_credentials =
|
||||
resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref())
|
||||
.await;
|
||||
let host_credentials = resolve_channel_host_credentials(
|
||||
&self.capabilities,
|
||||
self.secrets_store.as_deref(),
|
||||
&self.owner_scope_id,
|
||||
)
|
||||
.await;
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
let workspace_store = self.workspace_store.clone();
|
||||
|
||||
@@ -1414,9 +1520,12 @@ impl WasmChannel {
|
||||
let timeout = self.runtime.config().callback_timeout;
|
||||
let channel_name = self.name.clone();
|
||||
let credentials = self.get_credentials().await;
|
||||
let host_credentials =
|
||||
resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref())
|
||||
.await;
|
||||
let host_credentials = resolve_channel_host_credentials(
|
||||
&self.capabilities,
|
||||
self.secrets_store.as_deref(),
|
||||
&self.owner_scope_id,
|
||||
)
|
||||
.await;
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
|
||||
// Prepare response data
|
||||
@@ -1555,9 +1664,12 @@ impl WasmChannel {
|
||||
let timeout = self.runtime.config().callback_timeout;
|
||||
let channel_name = self.name.clone();
|
||||
let credentials = self.get_credentials().await;
|
||||
let host_credentials =
|
||||
resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref())
|
||||
.await;
|
||||
let host_credentials = resolve_channel_host_credentials(
|
||||
&self.capabilities,
|
||||
self.secrets_store.as_deref(),
|
||||
&self.owner_scope_id,
|
||||
)
|
||||
.await;
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
|
||||
let user_id = user_id.to_string();
|
||||
@@ -1659,9 +1771,12 @@ impl WasmChannel {
|
||||
let timeout = self.runtime.config().callback_timeout;
|
||||
let channel_name = self.name.clone();
|
||||
let credentials = self.get_credentials().await;
|
||||
let host_credentials =
|
||||
resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref())
|
||||
.await;
|
||||
let host_credentials = resolve_channel_host_credentials(
|
||||
&self.capabilities,
|
||||
self.secrets_store.as_deref(),
|
||||
&self.owner_scope_id,
|
||||
)
|
||||
.await;
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
|
||||
let Some(wit_update) = status_to_wit(status, metadata) else {
|
||||
@@ -1831,6 +1946,7 @@ impl WasmChannel {
|
||||
let repeater_host_credentials = resolve_channel_host_credentials(
|
||||
&self.capabilities,
|
||||
self.secrets_store.as_deref(),
|
||||
&self.owner_scope_id,
|
||||
)
|
||||
.await;
|
||||
let pairing_store = self.pairing_store.clone();
|
||||
@@ -2027,8 +2143,16 @@ impl WasmChannel {
|
||||
}
|
||||
}
|
||||
|
||||
let (resolved_user_id, is_owner_sender) = resolve_message_scope(
|
||||
&self.owner_scope_id,
|
||||
self.owner_actor_id.as_deref(),
|
||||
&emitted.user_id,
|
||||
);
|
||||
|
||||
// Convert to IncomingMessage
|
||||
let mut msg = IncomingMessage::new(&self.name, &emitted.user_id, &emitted.content);
|
||||
let mut msg = IncomingMessage::new(&self.name, &resolved_user_id, &emitted.content)
|
||||
.with_owner_id(&self.owner_scope_id)
|
||||
.with_sender_id(&emitted.user_id);
|
||||
|
||||
if let Some(name) = emitted.user_name {
|
||||
msg = msg.with_user_name(name);
|
||||
@@ -2060,9 +2184,9 @@ impl WasmChannel {
|
||||
}
|
||||
|
||||
// Parse metadata JSON
|
||||
if let Ok(metadata) = serde_json::from_str(&emitted.metadata_json) {
|
||||
msg = msg.with_metadata(metadata);
|
||||
// Store for broadcast routing (chat_id etc.)
|
||||
msg = apply_emitted_metadata(msg, &emitted.metadata_json);
|
||||
if is_owner_sender {
|
||||
// Store for owner-target routing (chat_id etc.).
|
||||
self.update_broadcast_metadata(&emitted.metadata_json).await;
|
||||
}
|
||||
|
||||
@@ -2112,6 +2236,8 @@ impl WasmChannel {
|
||||
let last_broadcast_metadata = self.last_broadcast_metadata.clone();
|
||||
let settings_store = self.settings_store.clone();
|
||||
let poll_secrets_store = self.secrets_store.clone();
|
||||
let owner_scope_id = self.owner_scope_id.clone();
|
||||
let owner_actor_id = self.owner_actor_id.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut interval_timer = tokio::time::interval(interval);
|
||||
@@ -2129,6 +2255,7 @@ impl WasmChannel {
|
||||
let host_credentials = resolve_channel_host_credentials(
|
||||
&poll_capabilities,
|
||||
poll_secrets_store.as_deref(),
|
||||
&owner_scope_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -2150,12 +2277,16 @@ impl WasmChannel {
|
||||
// Process any emitted messages
|
||||
if !emitted_messages.is_empty()
|
||||
&& let Err(e) = Self::dispatch_emitted_messages(
|
||||
&channel_name,
|
||||
EmitDispatchContext {
|
||||
channel_name: &channel_name,
|
||||
owner_scope_id: &owner_scope_id,
|
||||
owner_actor_id: owner_actor_id.as_deref(),
|
||||
message_tx: &message_tx,
|
||||
rate_limiter: &rate_limiter,
|
||||
last_broadcast_metadata: &last_broadcast_metadata,
|
||||
settings_store: settings_store.as_ref(),
|
||||
},
|
||||
emitted_messages,
|
||||
&message_tx,
|
||||
&rate_limiter,
|
||||
&last_broadcast_metadata,
|
||||
settings_store.as_ref(),
|
||||
).await {
|
||||
tracing::warn!(
|
||||
channel = %channel_name,
|
||||
@@ -2277,25 +2408,21 @@ impl WasmChannel {
|
||||
/// This is a static helper used by the polling loop since it doesn't have
|
||||
/// access to `&self`.
|
||||
async fn dispatch_emitted_messages(
|
||||
channel_name: &str,
|
||||
dispatch: EmitDispatchContext<'_>,
|
||||
messages: Vec<EmittedMessage>,
|
||||
message_tx: &RwLock<Option<mpsc::Sender<IncomingMessage>>>,
|
||||
rate_limiter: &RwLock<ChannelEmitRateLimiter>,
|
||||
last_broadcast_metadata: &tokio::sync::RwLock<Option<String>>,
|
||||
settings_store: Option<&Arc<dyn crate::db::SettingsStore>>,
|
||||
) -> Result<(), WasmChannelError> {
|
||||
tracing::info!(
|
||||
channel = %channel_name,
|
||||
channel = %dispatch.channel_name,
|
||||
message_count = messages.len(),
|
||||
"Processing emitted messages from polling callback"
|
||||
);
|
||||
|
||||
// Clone sender to avoid holding RwLock read guard across send().await in the loop
|
||||
let tx = {
|
||||
let tx_guard = message_tx.read().await;
|
||||
let tx_guard = dispatch.message_tx.read().await;
|
||||
let Some(tx) = tx_guard.as_ref() else {
|
||||
tracing::error!(
|
||||
channel = %channel_name,
|
||||
channel = %dispatch.channel_name,
|
||||
count = messages.len(),
|
||||
"Messages emitted but no sender available - channel may not be started!"
|
||||
);
|
||||
@@ -2307,20 +2434,29 @@ impl WasmChannel {
|
||||
for emitted in messages {
|
||||
// Check rate limit — acquire and release the write lock before send().await
|
||||
{
|
||||
let mut limiter = rate_limiter.write().await;
|
||||
let mut limiter = dispatch.rate_limiter.write().await;
|
||||
if !limiter.check_and_record() {
|
||||
tracing::warn!(
|
||||
channel = %channel_name,
|
||||
channel = %dispatch.channel_name,
|
||||
"Message emission rate limited"
|
||||
);
|
||||
return Err(WasmChannelError::EmitRateLimited {
|
||||
name: channel_name.to_string(),
|
||||
name: dispatch.channel_name.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let (resolved_user_id, is_owner_sender) = resolve_message_scope(
|
||||
dispatch.owner_scope_id,
|
||||
dispatch.owner_actor_id,
|
||||
&emitted.user_id,
|
||||
);
|
||||
|
||||
// Convert to IncomingMessage
|
||||
let mut msg = IncomingMessage::new(channel_name, &emitted.user_id, &emitted.content);
|
||||
let mut msg =
|
||||
IncomingMessage::new(dispatch.channel_name, &resolved_user_id, &emitted.content)
|
||||
.with_owner_id(dispatch.owner_scope_id)
|
||||
.with_sender_id(&emitted.user_id);
|
||||
|
||||
if let Some(name) = emitted.user_name {
|
||||
msg = msg.with_user_name(name);
|
||||
@@ -2351,22 +2487,22 @@ impl WasmChannel {
|
||||
msg = msg.with_attachments(incoming_attachments);
|
||||
}
|
||||
|
||||
// Parse metadata JSON
|
||||
if let Ok(metadata) = serde_json::from_str(&emitted.metadata_json) {
|
||||
msg = msg.with_metadata(metadata);
|
||||
// Store for broadcast routing (chat_id etc.)
|
||||
msg = apply_emitted_metadata(msg, &emitted.metadata_json);
|
||||
if is_owner_sender {
|
||||
// Store for owner-target routing (chat_id etc.)
|
||||
do_update_broadcast_metadata(
|
||||
channel_name,
|
||||
dispatch.channel_name,
|
||||
dispatch.owner_scope_id,
|
||||
&emitted.metadata_json,
|
||||
last_broadcast_metadata,
|
||||
settings_store,
|
||||
dispatch.last_broadcast_metadata,
|
||||
dispatch.settings_store,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Send to stream — no locks held across this await
|
||||
tracing::info!(
|
||||
channel = %channel_name,
|
||||
channel = %dispatch.channel_name,
|
||||
user_id = %emitted.user_id,
|
||||
content_len = emitted.content.len(),
|
||||
attachment_count = msg.attachments.len(),
|
||||
@@ -2375,14 +2511,14 @@ impl WasmChannel {
|
||||
|
||||
if tx.send(msg).await.is_err() {
|
||||
tracing::error!(
|
||||
channel = %channel_name,
|
||||
channel = %dispatch.channel_name,
|
||||
"Failed to send polled message, channel closed"
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
channel = %channel_name,
|
||||
channel = %dispatch.channel_name,
|
||||
"Message successfully sent to agent queue"
|
||||
);
|
||||
}
|
||||
@@ -2391,6 +2527,16 @@ impl WasmChannel {
|
||||
}
|
||||
}
|
||||
|
||||
struct EmitDispatchContext<'a> {
|
||||
channel_name: &'a str,
|
||||
owner_scope_id: &'a str,
|
||||
owner_actor_id: Option<&'a str>,
|
||||
message_tx: &'a RwLock<Option<mpsc::Sender<IncomingMessage>>>,
|
||||
rate_limiter: &'a RwLock<ChannelEmitRateLimiter>,
|
||||
last_broadcast_metadata: &'a tokio::sync::RwLock<Option<String>>,
|
||||
settings_store: Option<&'a Arc<dyn crate::db::SettingsStore>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for WasmChannel {
|
||||
fn name(&self) -> &str {
|
||||
@@ -2490,8 +2636,11 @@ impl Channel for WasmChannel {
|
||||
// The original metadata contains channel-specific routing info (e.g., Telegram chat_id)
|
||||
// that the WASM channel needs to send the reply to the correct destination.
|
||||
let metadata_json = serde_json::to_string(&msg.metadata).unwrap_or_default();
|
||||
// Store for broadcast routing (chat_id etc.)
|
||||
self.update_broadcast_metadata(&metadata_json).await;
|
||||
// Store for owner-target routing (chat_id etc.) only when the configured
|
||||
// owner is the actor in this conversation.
|
||||
if msg.user_id == self.owner_scope_id {
|
||||
self.update_broadcast_metadata(&metadata_json).await;
|
||||
}
|
||||
self.call_on_respond(
|
||||
msg.id,
|
||||
&response.content,
|
||||
@@ -2514,8 +2663,24 @@ impl Channel for WasmChannel {
|
||||
response: OutgoingResponse,
|
||||
) -> Result<(), ChannelError> {
|
||||
self.cancel_typing_task().await;
|
||||
let resolved_target = if uses_owner_broadcast_target(user_id, &self.owner_scope_id) {
|
||||
let metadata = self.last_broadcast_metadata.read().await.clone().ok_or_else(|| {
|
||||
missing_routing_target_error(
|
||||
&self.name,
|
||||
format!(
|
||||
"No stored owner routing target for channel '{}'. Send a message from the owner on this channel first.",
|
||||
self.name
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
resolve_owner_broadcast_target(&self.name, &metadata)?
|
||||
} else {
|
||||
user_id.to_string()
|
||||
};
|
||||
|
||||
self.call_on_broadcast(
|
||||
user_id,
|
||||
&resolved_target,
|
||||
&response.content,
|
||||
response.thread_id.as_deref(),
|
||||
&response.attachments,
|
||||
@@ -2931,6 +3096,7 @@ fn extract_host_from_url(url: &str) -> Option<String> {
|
||||
async fn resolve_channel_host_credentials(
|
||||
capabilities: &ChannelCapabilities,
|
||||
store: Option<&(dyn SecretsStore + Send + Sync)>,
|
||||
owner_scope_id: &str,
|
||||
) -> Vec<ResolvedHostCredential> {
|
||||
let store = match store {
|
||||
Some(s) => s,
|
||||
@@ -2957,7 +3123,10 @@ async fn resolve_channel_host_credentials(
|
||||
continue;
|
||||
}
|
||||
|
||||
let secret = match store.get_decrypted("default", &mapping.secret_name).await {
|
||||
let secret = match store
|
||||
.get_decrypted(owner_scope_id, &mapping.secret_name)
|
||||
.await
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
@@ -3076,12 +3245,18 @@ mod tests {
|
||||
use crate::channels::wasm::runtime::{
|
||||
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
|
||||
};
|
||||
use crate::channels::wasm::wrapper::{HttpResponse, WasmChannel};
|
||||
use crate::channels::wasm::wrapper::{
|
||||
EmitDispatchContext, HttpResponse, WasmChannel, uses_owner_broadcast_target,
|
||||
};
|
||||
use crate::pairing::PairingStore;
|
||||
use crate::testing::credentials::TEST_TELEGRAM_BOT_TOKEN;
|
||||
use crate::tools::wasm::ResourceLimits;
|
||||
|
||||
fn create_test_channel() -> WasmChannel {
|
||||
create_test_channel_with_owner_scope("default")
|
||||
}
|
||||
|
||||
fn create_test_channel_with_owner_scope(owner_scope_id: &str) -> WasmChannel {
|
||||
let config = WasmChannelRuntimeConfig::for_testing();
|
||||
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
|
||||
|
||||
@@ -3098,6 +3273,7 @@ mod tests {
|
||||
runtime,
|
||||
prepared,
|
||||
capabilities,
|
||||
owner_scope_id,
|
||||
"{}".to_string(),
|
||||
Arc::new(PairingStore::new()),
|
||||
None,
|
||||
@@ -3185,7 +3361,7 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(result.is_ok()); // safety: test-only assertion
|
||||
assert!(result.unwrap().is_empty());
|
||||
}
|
||||
|
||||
@@ -3209,28 +3385,32 @@ mod tests {
|
||||
|
||||
let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None));
|
||||
let result = WasmChannel::dispatch_emitted_messages(
|
||||
"test-channel",
|
||||
EmitDispatchContext {
|
||||
channel_name: "test-channel",
|
||||
owner_scope_id: "default",
|
||||
owner_actor_id: None,
|
||||
message_tx: &message_tx,
|
||||
rate_limiter: &rate_limiter,
|
||||
last_broadcast_metadata: &last_broadcast_metadata,
|
||||
settings_store: None,
|
||||
},
|
||||
messages,
|
||||
&message_tx,
|
||||
&rate_limiter,
|
||||
&last_broadcast_metadata,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(result.is_ok()); // safety: test-only assertion
|
||||
|
||||
// Verify messages were sent
|
||||
let msg1 = rx.try_recv().expect("Should receive first message");
|
||||
assert_eq!(msg1.user_id, "user1");
|
||||
assert_eq!(msg1.content, "Hello from polling!");
|
||||
let msg1 = rx.try_recv().expect("Should receive first message"); // safety: test-only assertion
|
||||
assert_eq!(msg1.user_id, "user1"); // safety: test-only assertion
|
||||
assert_eq!(msg1.content, "Hello from polling!"); // safety: test-only assertion
|
||||
|
||||
let msg2 = rx.try_recv().expect("Should receive second message");
|
||||
assert_eq!(msg2.user_id, "user2");
|
||||
assert_eq!(msg2.content, "Another message");
|
||||
let msg2 = rx.try_recv().expect("Should receive second message"); // safety: test-only assertion
|
||||
assert_eq!(msg2.user_id, "user2"); // safety: test-only assertion
|
||||
assert_eq!(msg2.content, "Another message"); // safety: test-only assertion
|
||||
|
||||
// No more messages
|
||||
assert!(rx.try_recv().is_err());
|
||||
assert!(rx.try_recv().is_err()); // safety: test-only assertion
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -3250,12 +3430,16 @@ mod tests {
|
||||
// Should return Ok even without a sender (logs warning but doesn't fail)
|
||||
let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None));
|
||||
let result = WasmChannel::dispatch_emitted_messages(
|
||||
"test-channel",
|
||||
EmitDispatchContext {
|
||||
channel_name: "test-channel",
|
||||
owner_scope_id: "default",
|
||||
owner_actor_id: None,
|
||||
message_tx: &message_tx,
|
||||
rate_limiter: &rate_limiter,
|
||||
last_broadcast_metadata: &last_broadcast_metadata,
|
||||
settings_store: None,
|
||||
},
|
||||
messages,
|
||||
&message_tx,
|
||||
&rate_limiter,
|
||||
&last_broadcast_metadata,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -3284,6 +3468,7 @@ mod tests {
|
||||
runtime,
|
||||
prepared,
|
||||
capabilities,
|
||||
"default",
|
||||
"{}".to_string(),
|
||||
Arc::new(PairingStore::new()),
|
||||
None,
|
||||
@@ -4255,42 +4440,172 @@ mod tests {
|
||||
|
||||
let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None));
|
||||
let result = WasmChannel::dispatch_emitted_messages(
|
||||
"test-channel",
|
||||
EmitDispatchContext {
|
||||
channel_name: "test-channel",
|
||||
owner_scope_id: "default",
|
||||
owner_actor_id: None,
|
||||
message_tx: &message_tx,
|
||||
rate_limiter: &rate_limiter,
|
||||
last_broadcast_metadata: &last_broadcast_metadata,
|
||||
settings_store: None,
|
||||
},
|
||||
messages,
|
||||
&message_tx,
|
||||
&rate_limiter,
|
||||
&last_broadcast_metadata,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(result.is_ok()); // safety: test-only assertion
|
||||
|
||||
let msg = rx.try_recv().expect("Should receive message");
|
||||
assert_eq!(msg.content, "Check these files");
|
||||
assert_eq!(msg.attachments.len(), 2);
|
||||
let msg = rx.try_recv().expect("Should receive message"); // safety: test-only assertion
|
||||
assert_eq!(msg.content, "Check these files"); // safety: test-only assertion
|
||||
assert_eq!(msg.attachments.len(), 2); // safety: test-only assertion
|
||||
|
||||
// Verify first attachment
|
||||
assert_eq!(msg.attachments[0].id, "photo123");
|
||||
assert_eq!(msg.attachments[0].mime_type, "image/jpeg");
|
||||
assert_eq!(msg.attachments[0].filename, Some("cat.jpg".to_string()));
|
||||
assert_eq!(msg.attachments[0].size_bytes, Some(50_000));
|
||||
assert_eq!(msg.attachments[0].id, "photo123"); // safety: test-only assertion
|
||||
assert_eq!(msg.attachments[0].mime_type, "image/jpeg"); // safety: test-only assertion
|
||||
assert_eq!(msg.attachments[0].filename, Some("cat.jpg".to_string())); // safety: test-only assertion
|
||||
assert_eq!(msg.attachments[0].size_bytes, Some(50_000)); // safety: test-only assertion
|
||||
assert_eq!(
|
||||
msg.attachments[0].source_url,
|
||||
Some("https://api.telegram.org/file/photo123".to_string())
|
||||
);
|
||||
); // safety: test-only assertion
|
||||
|
||||
// Verify second attachment
|
||||
assert_eq!(msg.attachments[1].id, "doc456");
|
||||
assert_eq!(msg.attachments[1].mime_type, "application/pdf");
|
||||
assert_eq!(msg.attachments[1].id, "doc456"); // safety: test-only assertion
|
||||
assert_eq!(msg.attachments[1].mime_type, "application/pdf"); // safety: test-only assertion
|
||||
assert_eq!(
|
||||
msg.attachments[1].extracted_text,
|
||||
Some("Report contents...".to_string())
|
||||
);
|
||||
); // safety: test-only assertion
|
||||
assert_eq!(
|
||||
msg.attachments[1].storage_key,
|
||||
Some("store/doc456".to_string())
|
||||
);
|
||||
); // safety: test-only assertion
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dispatch_emitted_messages_owner_binding_sets_owner_scope() {
|
||||
use crate::channels::wasm::host::EmittedMessage;
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
|
||||
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
|
||||
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
|
||||
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
|
||||
crate::channels::wasm::capabilities::EmitRateLimitConfig::default(),
|
||||
),
|
||||
));
|
||||
let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None));
|
||||
|
||||
let messages = vec![
|
||||
EmittedMessage::new("telegram-owner", "Hello from owner")
|
||||
.with_metadata(r#"{"chat_id":12345}"#),
|
||||
];
|
||||
|
||||
let result = WasmChannel::dispatch_emitted_messages(
|
||||
EmitDispatchContext {
|
||||
channel_name: "telegram",
|
||||
owner_scope_id: "owner-scope",
|
||||
owner_actor_id: Some("telegram-owner"),
|
||||
message_tx: &message_tx,
|
||||
rate_limiter: &rate_limiter,
|
||||
last_broadcast_metadata: &last_broadcast_metadata,
|
||||
settings_store: None,
|
||||
},
|
||||
messages,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok()); // safety: test-only assertion
|
||||
|
||||
let msg = rx.try_recv().expect("Should receive message"); // safety: test-only assertion
|
||||
assert_eq!(msg.user_id, "owner-scope"); // safety: test-only assertion
|
||||
assert_eq!(msg.owner_id, "owner-scope"); // safety: test-only assertion
|
||||
assert_eq!(msg.sender_id, "telegram-owner"); // safety: test-only assertion
|
||||
assert_eq!(msg.conversation_scope(), Some("12345")); // safety: test-only assertion
|
||||
let stored_metadata = last_broadcast_metadata.read().await.clone();
|
||||
assert_eq!(stored_metadata.as_deref(), Some(r#"{"chat_id":12345}"#)); // safety: test-only assertion
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dispatch_emitted_messages_guest_sender_stays_isolated() {
|
||||
use crate::channels::wasm::host::EmittedMessage;
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
|
||||
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
|
||||
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
|
||||
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
|
||||
crate::channels::wasm::capabilities::EmitRateLimitConfig::default(),
|
||||
),
|
||||
));
|
||||
let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None));
|
||||
|
||||
let messages = vec![
|
||||
EmittedMessage::new("guest-42", "Hello from guest").with_metadata(r#"{"chat_id":999}"#),
|
||||
];
|
||||
|
||||
let result = WasmChannel::dispatch_emitted_messages(
|
||||
EmitDispatchContext {
|
||||
channel_name: "telegram",
|
||||
owner_scope_id: "owner-scope",
|
||||
owner_actor_id: Some("telegram-owner"),
|
||||
message_tx: &message_tx,
|
||||
rate_limiter: &rate_limiter,
|
||||
last_broadcast_metadata: &last_broadcast_metadata,
|
||||
settings_store: None,
|
||||
},
|
||||
messages,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok()); // safety: test-only assertion
|
||||
|
||||
let msg = rx.try_recv().expect("Should receive message"); // safety: test-only assertion
|
||||
assert_eq!(msg.user_id, "guest-42"); // safety: test-only assertion
|
||||
assert_eq!(msg.owner_id, "owner-scope"); // safety: test-only assertion
|
||||
assert_eq!(msg.sender_id, "guest-42"); // safety: test-only assertion
|
||||
assert_eq!(msg.conversation_scope(), Some("999")); // safety: test-only assertion
|
||||
assert!(last_broadcast_metadata.read().await.is_none()); // safety: test-only assertion
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_broadcast_owner_scope_uses_stored_owner_metadata() {
|
||||
let channel = create_test_channel_with_owner_scope("owner-scope")
|
||||
.with_owner_actor_id(Some("telegram-owner".to_string()));
|
||||
|
||||
*channel.last_broadcast_metadata.write().await = Some(r#"{"chat_id":12345}"#.to_string());
|
||||
|
||||
let result = channel
|
||||
.broadcast(
|
||||
"owner-scope",
|
||||
crate::channels::OutgoingResponse::text("hello owner"),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok()); // safety: test-only assertion
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_target_is_not_treated_as_owner_scope() {
|
||||
assert!(!uses_owner_broadcast_target("default", "owner-scope")); // safety: test-only assertion
|
||||
assert!(uses_owner_broadcast_target("default", "default")); // safety: test-only assertion
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_broadcast_owner_scope_requires_stored_metadata() {
|
||||
let channel = create_test_channel_with_owner_scope("owner-scope")
|
||||
.with_owner_actor_id(Some("telegram-owner".to_string()));
|
||||
|
||||
let result = channel
|
||||
.broadcast(
|
||||
"owner-scope",
|
||||
crate::channels::OutgoingResponse::text("hello owner"),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err()); // safety: test-only assertion
|
||||
let err = result.unwrap_err().to_string();
|
||||
let mentions_missing_owner_route =
|
||||
err.contains("Send a message from the owner on this channel first");
|
||||
assert!(mentions_missing_owner_route); // safety: test-only assertion
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -4310,20 +4625,24 @@ mod tests {
|
||||
|
||||
let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None));
|
||||
let result = WasmChannel::dispatch_emitted_messages(
|
||||
"test-channel",
|
||||
EmitDispatchContext {
|
||||
channel_name: "test-channel",
|
||||
owner_scope_id: "default",
|
||||
owner_actor_id: None,
|
||||
message_tx: &message_tx,
|
||||
rate_limiter: &rate_limiter,
|
||||
last_broadcast_metadata: &last_broadcast_metadata,
|
||||
settings_store: None,
|
||||
},
|
||||
messages,
|
||||
&message_tx,
|
||||
&rate_limiter,
|
||||
&last_broadcast_metadata,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(result.is_ok()); // safety: test-only assertion
|
||||
|
||||
let msg = rx.try_recv().expect("Should receive message");
|
||||
assert_eq!(msg.content, "Just text, no attachments");
|
||||
assert!(msg.attachments.is_empty());
|
||||
let msg = rx.try_recv().expect("Should receive message"); // safety: test-only assertion
|
||||
assert_eq!(msg.content, "Just text, no attachments"); // safety: test-only assertion
|
||||
assert!(msg.attachments.is_empty()); // safety: test-only assertion
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user