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:
Henry Park
2026-03-16 13:31:03 -07:00
committed by GitHub
parent 971b4c2ef4
commit 878a67cdb6
50 changed files with 2767 additions and 1071 deletions
+82 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+8 -2
View File
@@ -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");
+1 -1
View File
@@ -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
+1
View File
@@ -672,6 +672,7 @@ mod tests {
runtime,
prepared,
capabilities,
"default",
"{}".to_string(),
Arc::new(PairingStore::new()),
None,
+16 -6
View File
@@ -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
View File
@@ -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]