mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat: receive relay events via webhook callbacks instead of SSE Replace the SSE pull model with push-based webhook callbacks from channel-relay. Eliminates the reconnect loop, stream token auth, and SSE parser — events arrive via HTTP POST to /relay/events. - Add webhook handler with HMAC signature verification - Simplify RelayChannel to use mpsc from webhook handler - Remove SSE connect/reconnect/parse logic from RelayClient - Add register_callback() to RelayClient for callback URL registration - Update activation flow to create event channel and register callback - Wire relay webhook endpoint into web gateway * fix: address review feedback on webhook callback PR - Return 503 when relay event channel is full/closed (enables retry) - Reject malformed timestamps with 400 instead of proceeding - Allow relay activation without settings store (no-store/ephemeral mode) - Check installed_relay_extensions set in is_relay_channel for no-db mode - Fix staging test constructors for new RelayChannel signature * security: adapt relay client to new channel-relay auth model Adapts the relay integration to the hardened channel-relay security model: - Switch from X-API-Key header to Authorization: Bearer sk-agent-* for all relay API calls (chat-api token verification) - Remove register_callback() — PUT /callbacks endpoint removed - Remove event_callback_url from initiate_oauth() — parameter removed - Make signing_secret a required field in RelayConfig (new env var: CHANNEL_RELAY_SIGNING_SECRET) - Update integration tests for Bearer auth and removed endpoints Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * security: use server-side approval tokens, remove caller-supplied routing - Approval flow now calls POST /approvals to register server-side record, then embeds only the opaque approval_token in button value - Remove instance_id parameter from proxy_provider() — channel-relay no longer accepts it (uses verified identity) - Remove instance_id and user_id from initiate_oauth() — channel-relay derives them from the Bearer token - Add create_approval() to RelayClient Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: pass webhook_url during OAuth so callback_url is set on connection The channel-relay OAuth flow now accepts webhook_url to set the callback_url during connection creation. IronClaw computes its webhook URL from callback_base + webhook_path and passes it during initiate_oauth. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * security: remove webhook_url from OAuth initiation Channel-relay now derives the callback URL from chat-api's instance_url. IronClaw no longer supplies webhook_url during OAuth — the relay is the authority on where events get delivered. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore: cargo fmt Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * security: remove all URL params from OAuth initiation IronClaw no longer supplies any URLs to channel-relay. The relay derives all URLs from the trusted instance_url in chat-api. initiate_oauth() takes no parameters. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: restore CSRF nonce for OAuth callback validation Re-add nonce generation and secret storage in auth_channel_relay. The nonce is passed to channel-relay as state_nonce param (not a URL). Channel-relay embeds it in the signed state and appends it to the redirect URL so IronClaw's callback handler can validate and activate. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * security: per-instance callback signing secrets relay_signing_secret() now prefers OPENCLAW_GATEWAY_TOKEN (per-instance) over the shared CHANNEL_RELAY_SIGNING_SECRET. A compromised instance can no longer forge callbacks to other instances on the same relay. CHANNEL_RELAY_SIGNING_SECRET is now optional in RelayConfig. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * security: clean per-instance callback secrets, no shared secrets, no fallbacks Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: pass team_id to get_signing_secret for workspace-scoped lookup Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * security: remove sender_id from create_approval — relay derives it Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: remove stale relay sender_id validation * fix: harden relay webhook activation lifecycle --------- Co-authored-by: Pierre <[email protected]> Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
679 lines
22 KiB
Rust
679 lines
22 KiB
Rust
//! Channel trait implementation for channel-relay webhook callbacks.
|
|
//!
|
|
//! `RelayChannel` receives events from channel-relay via HTTP POST callbacks
|
|
//! (pushed through an mpsc channel by the webhook handler), converts them
|
|
//! to `IncomingMessage`s, and sends responses via the relay's provider-specific
|
|
//! proxy API (Slack).
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use async_trait::async_trait;
|
|
use tokio::sync::mpsc;
|
|
|
|
use crate::channels::relay::client::{ChannelEvent, RelayClient};
|
|
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
|
use crate::error::ChannelError;
|
|
|
|
/// Default channel name for the Slack relay integration.
|
|
pub const DEFAULT_RELAY_NAME: &str = "slack-relay";
|
|
|
|
/// The messaging provider backing a relay channel.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum RelayProvider {
|
|
Slack,
|
|
}
|
|
|
|
impl RelayProvider {
|
|
/// Provider string used in proxy API routes and metadata.
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
Self::Slack => "slack",
|
|
}
|
|
}
|
|
|
|
/// The default channel name for this provider.
|
|
pub fn channel_name(&self) -> &'static str {
|
|
match self {
|
|
Self::Slack => DEFAULT_RELAY_NAME,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Channel implementation that receives events from channel-relay via webhook callbacks.
|
|
pub struct RelayChannel {
|
|
client: RelayClient,
|
|
provider: RelayProvider,
|
|
team_id: String,
|
|
instance_id: String,
|
|
/// Sender side of the event channel — shared with the webhook handler.
|
|
event_tx: mpsc::Sender<ChannelEvent>,
|
|
/// Receiver side — taken once by `start()`.
|
|
event_rx: tokio::sync::Mutex<Option<mpsc::Receiver<ChannelEvent>>>,
|
|
}
|
|
|
|
impl RelayChannel {
|
|
/// Create a new relay channel for Slack (default provider).
|
|
pub fn new(
|
|
client: RelayClient,
|
|
team_id: String,
|
|
instance_id: String,
|
|
event_tx: mpsc::Sender<ChannelEvent>,
|
|
event_rx: mpsc::Receiver<ChannelEvent>,
|
|
) -> Self {
|
|
Self::new_with_provider(
|
|
client,
|
|
RelayProvider::Slack,
|
|
team_id,
|
|
instance_id,
|
|
event_tx,
|
|
event_rx,
|
|
)
|
|
}
|
|
|
|
/// Create a new relay channel with a specific provider.
|
|
pub fn new_with_provider(
|
|
client: RelayClient,
|
|
provider: RelayProvider,
|
|
team_id: String,
|
|
instance_id: String,
|
|
event_tx: mpsc::Sender<ChannelEvent>,
|
|
event_rx: mpsc::Receiver<ChannelEvent>,
|
|
) -> Self {
|
|
Self {
|
|
client,
|
|
provider,
|
|
team_id,
|
|
instance_id,
|
|
event_tx,
|
|
event_rx: tokio::sync::Mutex::new(Some(event_rx)),
|
|
}
|
|
}
|
|
|
|
/// Get a clone of the event sender for wiring into the webhook endpoint.
|
|
pub fn event_sender(&self) -> mpsc::Sender<ChannelEvent> {
|
|
self.event_tx.clone()
|
|
}
|
|
|
|
/// Build a provider-appropriate proxy body for sending a message.
|
|
fn build_send_body(
|
|
&self,
|
|
channel_id: &str,
|
|
text: &str,
|
|
thread_id: Option<&str>,
|
|
) -> (String, serde_json::Value) {
|
|
match self.provider {
|
|
RelayProvider::Slack => {
|
|
let mut body = serde_json::json!({
|
|
"channel": channel_id,
|
|
"text": text,
|
|
});
|
|
if let Some(tid) = thread_id {
|
|
body["thread_ts"] = serde_json::Value::String(tid.to_string());
|
|
}
|
|
("chat.postMessage".to_string(), body)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Send a message via the provider proxy.
|
|
async fn proxy_send(
|
|
&self,
|
|
team_id: &str,
|
|
method: &str,
|
|
body: serde_json::Value,
|
|
) -> Result<serde_json::Value, crate::channels::relay::client::RelayError> {
|
|
self.client
|
|
.proxy_provider(self.provider.as_str(), team_id, method, body)
|
|
.await
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Channel for RelayChannel {
|
|
fn name(&self) -> &str {
|
|
self.provider.channel_name()
|
|
}
|
|
|
|
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
|
let channel_name = self.name().to_string();
|
|
|
|
// Take the receiver (can only start once)
|
|
let mut event_rx =
|
|
self.event_rx
|
|
.lock()
|
|
.await
|
|
.take()
|
|
.ok_or_else(|| ChannelError::StartupFailed {
|
|
name: channel_name.clone(),
|
|
reason: "RelayChannel already started".to_string(),
|
|
})?;
|
|
|
|
let (tx, rx) = mpsc::channel(64);
|
|
let provider_str = self.provider.as_str().to_string();
|
|
let relay_name = channel_name.clone();
|
|
|
|
// Spawn a task that reads events from the webhook handler and converts to IncomingMessage
|
|
tokio::spawn(async move {
|
|
while let Some(event) = event_rx.recv().await {
|
|
// Validate required fields
|
|
if event.sender_id.is_empty()
|
|
|| event.channel_id.is_empty()
|
|
|| event.provider_scope.is_empty()
|
|
{
|
|
tracing::debug!(
|
|
event_type = %event.event_type,
|
|
sender_id = %event.sender_id,
|
|
channel_id = %event.channel_id,
|
|
"Relay: skipping event with missing required fields"
|
|
);
|
|
continue;
|
|
}
|
|
|
|
// Skip non-message events
|
|
if !event.is_message() {
|
|
tracing::debug!(
|
|
event_type = %event.event_type,
|
|
"Relay: skipping non-message event"
|
|
);
|
|
continue;
|
|
}
|
|
|
|
tracing::info!(
|
|
event_type = %event.event_type,
|
|
sender = %event.sender_id,
|
|
channel = %event.channel_id,
|
|
provider = %provider_str,
|
|
"Relay: received message from {}", provider_str
|
|
);
|
|
|
|
let msg = IncomingMessage::new(&relay_name, &event.sender_id, event.text())
|
|
.with_user_name(event.display_name())
|
|
.with_metadata(serde_json::json!({
|
|
"team_id": event.team_id(),
|
|
"channel_id": event.channel_id,
|
|
"sender_id": event.sender_id,
|
|
"sender_name": event.display_name(),
|
|
"event_type": event.event_type,
|
|
"thread_id": event.thread_id,
|
|
"provider": event.provider,
|
|
}));
|
|
|
|
let msg = if let Some(ref thread_id) = event.thread_id {
|
|
msg.with_thread(thread_id)
|
|
} else {
|
|
msg.with_thread(&event.channel_id)
|
|
};
|
|
|
|
if tx.send(msg).await.is_err() {
|
|
tracing::info!("Relay channel receiver dropped, stopping");
|
|
return;
|
|
}
|
|
}
|
|
|
|
tracing::info!("Relay event channel closed");
|
|
});
|
|
|
|
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
|
|
Ok(Box::pin(stream))
|
|
}
|
|
|
|
async fn respond(
|
|
&self,
|
|
msg: &IncomingMessage,
|
|
response: OutgoingResponse,
|
|
) -> Result<(), ChannelError> {
|
|
let channel_name = self.name().to_string();
|
|
let metadata = &msg.metadata;
|
|
let team_id = metadata
|
|
.get("team_id")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or(&self.team_id);
|
|
let channel_id = metadata
|
|
.get("channel_id")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| ChannelError::SendFailed {
|
|
name: channel_name.clone(),
|
|
reason: "Missing channel_id in message metadata".to_string(),
|
|
})?;
|
|
|
|
// Determine thread_id from response or metadata
|
|
let thread_id = response
|
|
.thread_id
|
|
.as_deref()
|
|
.or_else(|| metadata.get("thread_id").and_then(|v| v.as_str()));
|
|
|
|
let (method, body) = self.build_send_body(channel_id, &response.content, thread_id);
|
|
|
|
self.proxy_send(team_id, &method, body)
|
|
.await
|
|
.map_err(|e| ChannelError::SendFailed {
|
|
name: channel_name,
|
|
reason: e.to_string(),
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn send_status(
|
|
&self,
|
|
status: StatusUpdate,
|
|
metadata: &serde_json::Value,
|
|
) -> Result<(), ChannelError> {
|
|
// Only handle ApprovalNeeded — all other variants are no-ops
|
|
let StatusUpdate::ApprovalNeeded {
|
|
request_id,
|
|
tool_name,
|
|
description,
|
|
parameters,
|
|
allow_always: _,
|
|
} = status
|
|
else {
|
|
return Ok(());
|
|
};
|
|
|
|
// Only send buttons in DMs (dispatcher gates upstream, but guard here too)
|
|
let event_type = metadata
|
|
.get("event_type")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("");
|
|
if event_type != "direct_message" {
|
|
tracing::warn!(
|
|
tool = %tool_name,
|
|
event_type,
|
|
"Approval requested in non-DM, skipping buttons"
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
// Extract required metadata — error if missing
|
|
let channel_id = metadata
|
|
.get("channel_id")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| ChannelError::SendFailed {
|
|
name: self.name().to_string(),
|
|
reason: "Missing channel_id for approval buttons".into(),
|
|
})?;
|
|
let thread_id = metadata.get("thread_id").and_then(|v| v.as_str());
|
|
let team_id = metadata
|
|
.get("team_id")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or(&self.team_id);
|
|
|
|
// Register server-side approval record and get opaque token.
|
|
// The button value contains ONLY the token — no routing fields.
|
|
let approval_token = self
|
|
.client
|
|
.create_approval(team_id, channel_id, thread_id, &request_id)
|
|
.await
|
|
.map_err(|e| ChannelError::SendFailed {
|
|
name: self.name().to_string(),
|
|
reason: format!("Failed to register approval: {e}"),
|
|
})?;
|
|
let value_payload = serde_json::json!({
|
|
"approval_token": approval_token,
|
|
});
|
|
let value_str = value_payload.to_string();
|
|
|
|
// Parameters are already redacted via redact_params() in dispatcher.rs
|
|
let params_display =
|
|
serde_json::to_string_pretty(¶meters).unwrap_or_else(|_| parameters.to_string());
|
|
|
|
let blocks = serde_json::json!([
|
|
{
|
|
"type": "section",
|
|
"text": {
|
|
"type": "mrkdwn",
|
|
"text": format!(
|
|
"*Tool approval required*\n`{tool_name}`: {description}\n```{params_display}```"
|
|
)
|
|
}
|
|
},
|
|
{
|
|
"type": "actions",
|
|
"elements": [
|
|
{
|
|
"type": "button",
|
|
"text": { "type": "plain_text", "text": "Approve" },
|
|
"style": "primary",
|
|
"action_id": "approve_tool",
|
|
"value": value_str,
|
|
},
|
|
{
|
|
"type": "button",
|
|
"text": { "type": "plain_text", "text": "Deny" },
|
|
"style": "danger",
|
|
"action_id": "deny_tool",
|
|
"value": value_str,
|
|
}
|
|
]
|
|
}
|
|
]);
|
|
|
|
let mut body = serde_json::json!({
|
|
"channel": channel_id,
|
|
"text": format!("Tool approval required: {tool_name} - {description}"),
|
|
"blocks": blocks,
|
|
});
|
|
if let Some(tid) = thread_id {
|
|
body["thread_ts"] = serde_json::Value::String(tid.to_string());
|
|
}
|
|
|
|
self.proxy_send(team_id, "chat.postMessage", body)
|
|
.await
|
|
.map_err(|e| ChannelError::SendFailed {
|
|
name: self.name().to_string(),
|
|
reason: e.to_string(),
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn broadcast(
|
|
&self,
|
|
target: &str,
|
|
response: OutgoingResponse,
|
|
) -> Result<(), ChannelError> {
|
|
let channel_name = self.name().to_string();
|
|
|
|
// Determine thread_id from response or metadata
|
|
let thread_id = response
|
|
.thread_id
|
|
.as_deref()
|
|
.or_else(|| response.metadata.get("thread_ts").and_then(|v| v.as_str()));
|
|
|
|
let (method, body) = self.build_send_body(target, &response.content, thread_id);
|
|
|
|
self.proxy_send(&self.team_id, &method, body)
|
|
.await
|
|
.map_err(|e| ChannelError::SendFailed {
|
|
name: channel_name,
|
|
reason: e.to_string(),
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn health_check(&self) -> Result<(), ChannelError> {
|
|
self.client
|
|
.list_connections(&self.instance_id)
|
|
.await
|
|
.map_err(|_| ChannelError::HealthCheckFailed {
|
|
name: self.name().to_string(),
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
fn conversation_context(&self, metadata: &serde_json::Value) -> HashMap<String, String> {
|
|
let mut ctx = HashMap::new();
|
|
|
|
if let Some(sender) = metadata.get("sender_name").and_then(|v| v.as_str()) {
|
|
ctx.insert("sender".to_string(), sender.to_string());
|
|
}
|
|
if let Some(sender_id) = metadata.get("sender_id").and_then(|v| v.as_str()) {
|
|
ctx.insert("sender_uuid".to_string(), sender_id.to_string());
|
|
}
|
|
if let Some(channel_id) = metadata.get("channel_id").and_then(|v| v.as_str()) {
|
|
ctx.insert("group".to_string(), channel_id.to_string());
|
|
}
|
|
ctx.insert("platform".to_string(), self.provider.as_str().to_string());
|
|
|
|
ctx
|
|
}
|
|
|
|
async fn shutdown(&self) -> Result<(), ChannelError> {
|
|
// Relay cleanup is driven by the extension manager dropping the shared
|
|
// sender and removing the channel from the channel manager.
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn test_client() -> RelayClient {
|
|
RelayClient::new(
|
|
"http://localhost:3001".into(),
|
|
secrecy::SecretString::from("key".to_string()),
|
|
30,
|
|
)
|
|
.expect("client")
|
|
}
|
|
|
|
fn make_channel() -> RelayChannel {
|
|
let (tx, rx) = mpsc::channel(64);
|
|
RelayChannel::new(test_client(), "T123".into(), "inst1".into(), tx, rx)
|
|
}
|
|
|
|
#[test]
|
|
fn relay_channel_name() {
|
|
let channel = make_channel();
|
|
assert_eq!(channel.name(), DEFAULT_RELAY_NAME);
|
|
}
|
|
|
|
#[test]
|
|
fn conversation_context_extracts_metadata() {
|
|
let channel = make_channel();
|
|
|
|
let metadata = serde_json::json!({
|
|
"sender_name": "bob",
|
|
"sender_id": "U123",
|
|
"channel_id": "C456",
|
|
});
|
|
let ctx = channel.conversation_context(&metadata);
|
|
assert_eq!(ctx.get("sender"), Some(&"bob".to_string()));
|
|
assert_eq!(ctx.get("sender_uuid"), Some(&"U123".to_string()));
|
|
assert_eq!(ctx.get("platform"), Some(&"slack".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn metadata_shape_includes_event_type_and_sender_name() {
|
|
let metadata = serde_json::json!({
|
|
"team_id": "T123",
|
|
"channel_id": "C456",
|
|
"sender_id": "U789",
|
|
"sender_name": "alice",
|
|
"event_type": "direct_message",
|
|
"thread_id": null,
|
|
"provider": "slack",
|
|
});
|
|
assert_eq!(
|
|
metadata.get("event_type").and_then(|v| v.as_str()),
|
|
Some("direct_message")
|
|
);
|
|
assert_eq!(
|
|
metadata.get("sender_name").and_then(|v| v.as_str()),
|
|
Some("alice")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_send_body_slack() {
|
|
let channel = make_channel();
|
|
let (method, body) = channel.build_send_body("C456", "hello", Some("1234567.890"));
|
|
assert_eq!(method, "chat.postMessage");
|
|
assert_eq!(body["channel"], "C456");
|
|
assert_eq!(body["text"], "hello");
|
|
assert_eq!(body["thread_ts"], "1234567.890");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn start_processes_events() {
|
|
let (tx, rx) = mpsc::channel(64);
|
|
let channel =
|
|
RelayChannel::new(test_client(), "T123".into(), "inst1".into(), tx.clone(), rx);
|
|
|
|
let mut stream = channel.start().await.unwrap();
|
|
|
|
// Send an event
|
|
tx.send(ChannelEvent {
|
|
id: "1".into(),
|
|
event_type: "message".into(),
|
|
provider: "slack".into(),
|
|
provider_scope: "T123".into(),
|
|
channel_id: "C456".into(),
|
|
sender_id: "U789".into(),
|
|
sender_name: Some("alice".into()),
|
|
content: Some("hello".into()),
|
|
thread_id: None,
|
|
raw: serde_json::Value::Null,
|
|
timestamp: None,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
use futures::StreamExt;
|
|
let msg = tokio::time::timeout(std::time::Duration::from_secs(1), stream.next())
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
assert_eq!(msg.content, "hello");
|
|
assert_eq!(msg.user_id, "U789");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn start_skips_non_message_events() {
|
|
let (tx, rx) = mpsc::channel(64);
|
|
let channel =
|
|
RelayChannel::new(test_client(), "T123".into(), "inst1".into(), tx.clone(), rx);
|
|
|
|
let mut stream = channel.start().await.unwrap();
|
|
|
|
// Send a non-message event (should be skipped)
|
|
tx.send(ChannelEvent {
|
|
id: "1".into(),
|
|
event_type: "reaction".into(),
|
|
provider: "slack".into(),
|
|
provider_scope: "T123".into(),
|
|
channel_id: "C456".into(),
|
|
sender_id: "U789".into(),
|
|
sender_name: None,
|
|
content: None,
|
|
thread_id: None,
|
|
raw: serde_json::Value::Null,
|
|
timestamp: None,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
// Send a real message
|
|
tx.send(ChannelEvent {
|
|
id: "2".into(),
|
|
event_type: "message".into(),
|
|
provider: "slack".into(),
|
|
provider_scope: "T123".into(),
|
|
channel_id: "C456".into(),
|
|
sender_id: "U789".into(),
|
|
sender_name: None,
|
|
content: Some("real message".into()),
|
|
thread_id: None,
|
|
raw: serde_json::Value::Null,
|
|
timestamp: None,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
use futures::StreamExt;
|
|
let msg = tokio::time::timeout(std::time::Duration::from_secs(1), stream.next())
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
assert_eq!(msg.content, "real message");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_send_status_non_approval_is_noop() {
|
|
let channel = make_channel();
|
|
let metadata = serde_json::json!({});
|
|
let result = channel
|
|
.send_status(
|
|
StatusUpdate::ToolStarted {
|
|
name: "echo".into(),
|
|
},
|
|
&metadata,
|
|
)
|
|
.await;
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_send_status_approval_non_dm_skips() {
|
|
let channel = make_channel();
|
|
let metadata = serde_json::json!({
|
|
"event_type": "message",
|
|
"channel_id": "C456",
|
|
"sender_id": "U789",
|
|
});
|
|
let result = channel
|
|
.send_status(
|
|
StatusUpdate::ApprovalNeeded {
|
|
request_id: "req1".into(),
|
|
tool_name: "shell".into(),
|
|
description: "run command".into(),
|
|
parameters: serde_json::json!({}),
|
|
allow_always: true,
|
|
},
|
|
&metadata,
|
|
)
|
|
.await;
|
|
// Non-DM approval requests are silently skipped (no HTTP call)
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_send_status_approval_dm_missing_channel_id_errors() {
|
|
let channel = make_channel();
|
|
let metadata = serde_json::json!({
|
|
"event_type": "direct_message",
|
|
"sender_id": "U789",
|
|
});
|
|
let result = channel
|
|
.send_status(
|
|
StatusUpdate::ApprovalNeeded {
|
|
request_id: "req1".into(),
|
|
tool_name: "shell".into(),
|
|
description: "run command".into(),
|
|
parameters: serde_json::json!({}),
|
|
allow_always: true,
|
|
},
|
|
&metadata,
|
|
)
|
|
.await;
|
|
assert!(result.is_err());
|
|
let err = result.unwrap_err().to_string();
|
|
assert!(
|
|
err.contains("channel_id"),
|
|
"expected channel_id error, got: {err}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_send_status_approval_dm_without_sender_id_is_ok() {
|
|
let channel = make_channel();
|
|
let metadata = serde_json::json!({
|
|
"event_type": "direct_message",
|
|
"channel_id": "C456",
|
|
});
|
|
let result = channel
|
|
.send_status(
|
|
StatusUpdate::ApprovalNeeded {
|
|
request_id: "req1".into(),
|
|
tool_name: "shell".into(),
|
|
description: "run command".into(),
|
|
parameters: serde_json::json!({}),
|
|
allow_always: true,
|
|
},
|
|
&metadata,
|
|
)
|
|
.await;
|
|
assert!(result.is_err());
|
|
let err = result.unwrap_err().to_string();
|
|
assert!(
|
|
!err.contains("sender_id"),
|
|
"sender_id should not be required anymore, got: {err}"
|
|
);
|
|
}
|
|
}
|