mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
PR #1681 introduced 23 debug-level log statements across relay client, web server handlers, and extension manager functions. Many of these fire on every HTTP request or in loops (e.g. has_stored_team_id called per extension in list_installed). Downgrade them to trace level to reduce noise at the default debug log level while preserving warn/info logs for actionable diagnostics. Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
490 lines
16 KiB
Rust
490 lines
16 KiB
Rust
//! HTTP client for the channel-relay service.
|
|
//!
|
|
//! Wraps reqwest for all channel-relay API calls: OAuth initiation,
|
|
//! approvals, signing-secret fetch, and Slack API proxy.
|
|
|
|
use secrecy::{ExposeSecret, SecretString};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Known relay event types.
|
|
pub mod event_types {
|
|
pub const MESSAGE: &str = "message";
|
|
pub const DIRECT_MESSAGE: &str = "direct_message";
|
|
pub const MENTION: &str = "mention";
|
|
}
|
|
|
|
/// A parsed event from the channel-relay webhook callback.
|
|
///
|
|
/// Field names match the channel-relay `ChannelEvent` struct exactly.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ChannelEvent {
|
|
/// Unique event ID.
|
|
#[serde(default)]
|
|
pub id: String,
|
|
/// Event type enum from channel-relay (e.g., "direct_message", "message", "mention").
|
|
pub event_type: String,
|
|
/// Provider (e.g., "slack").
|
|
#[serde(default)]
|
|
pub provider: String,
|
|
/// Team/workspace ID (called `provider_scope` in channel-relay).
|
|
#[serde(alias = "team_id", default)]
|
|
pub provider_scope: String,
|
|
/// Channel or DM conversation ID.
|
|
#[serde(default)]
|
|
pub channel_id: String,
|
|
/// Sender user ID.
|
|
#[serde(default)]
|
|
pub sender_id: String,
|
|
/// Sender display name.
|
|
#[serde(default)]
|
|
pub sender_name: Option<String>,
|
|
/// Message text content (called `content` in channel-relay).
|
|
#[serde(alias = "text", default)]
|
|
pub content: Option<String>,
|
|
/// Thread ID (for threaded replies, called `thread_id` in channel-relay).
|
|
#[serde(alias = "thread_ts", default)]
|
|
pub thread_id: Option<String>,
|
|
/// Full raw event data.
|
|
#[serde(default)]
|
|
pub raw: serde_json::Value,
|
|
/// Event timestamp (ISO 8601 from channel-relay).
|
|
#[serde(default)]
|
|
pub timestamp: Option<String>,
|
|
}
|
|
|
|
impl ChannelEvent {
|
|
/// Get the team_id (provider_scope).
|
|
pub fn team_id(&self) -> &str {
|
|
&self.provider_scope
|
|
}
|
|
|
|
/// Get the message text content.
|
|
pub fn text(&self) -> &str {
|
|
self.content.as_deref().unwrap_or("")
|
|
}
|
|
|
|
/// Get the sender name or fallback to sender_id.
|
|
pub fn display_name(&self) -> &str {
|
|
self.sender_name.as_deref().unwrap_or(&self.sender_id)
|
|
}
|
|
|
|
/// Check if this is a message-like event that should be forwarded to the agent.
|
|
pub fn is_message(&self) -> bool {
|
|
matches!(
|
|
self.event_type.as_str(),
|
|
event_types::MESSAGE | event_types::DIRECT_MESSAGE | event_types::MENTION
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Connection info returned by list_connections.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Connection {
|
|
pub provider: String,
|
|
pub team_id: String,
|
|
pub team_name: Option<String>,
|
|
pub connected: bool,
|
|
}
|
|
|
|
/// HTTP client for the channel-relay service.
|
|
#[derive(Clone)]
|
|
pub struct RelayClient {
|
|
http: reqwest::Client,
|
|
base_url: String,
|
|
api_key: SecretString,
|
|
}
|
|
|
|
impl RelayClient {
|
|
/// Create a new relay client.
|
|
pub fn new(
|
|
base_url: String,
|
|
api_key: SecretString,
|
|
request_timeout_secs: u64,
|
|
) -> Result<Self, RelayError> {
|
|
let http = reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(request_timeout_secs))
|
|
.redirect(reqwest::redirect::Policy::none())
|
|
.build()
|
|
.map_err(|e| RelayError::Network(format!("Failed to build HTTP client: {e}")))?;
|
|
|
|
Ok(Self {
|
|
http,
|
|
base_url: base_url.trim_end_matches('/').to_string(),
|
|
api_key,
|
|
})
|
|
}
|
|
|
|
/// Initiate Slack OAuth flow via channel-relay.
|
|
///
|
|
/// Calls `GET /oauth/slack/auth` with `redirect(Policy::none())` and
|
|
/// returns the `Location` header (Slack OAuth URL) without following it.
|
|
/// Initiate Slack OAuth. Channel-relay derives all URLs from the trusted
|
|
/// instance_url in chat-api. IronClaw only passes an optional CSRF nonce
|
|
/// for validating the callback — no URLs.
|
|
pub async fn initiate_oauth(&self, state_nonce: Option<&str>) -> Result<String, RelayError> {
|
|
let url = format!("{}/oauth/slack/auth", self.base_url);
|
|
tracing::trace!(relay_url = %url, "RelayClient::initiate_oauth: sending request");
|
|
let mut query: Vec<(&str, &str)> = vec![];
|
|
if let Some(nonce) = state_nonce {
|
|
query.push(("state_nonce", nonce));
|
|
}
|
|
let resp = self
|
|
.http
|
|
.get(&url)
|
|
.bearer_auth(self.api_key.expose_secret())
|
|
.query(&query)
|
|
.send()
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::warn!(
|
|
relay_url = %url,
|
|
error = %e,
|
|
"RelayClient::initiate_oauth: network request failed"
|
|
);
|
|
RelayError::Network(e.to_string())
|
|
})?;
|
|
tracing::trace!(
|
|
relay_url = %url,
|
|
status = %resp.status(),
|
|
"RelayClient::initiate_oauth: received response"
|
|
);
|
|
|
|
let status = resp.status();
|
|
if status.is_redirection() {
|
|
let location = resp
|
|
.headers()
|
|
.get(reqwest::header::LOCATION)
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(|s| s.to_string())
|
|
.ok_or_else(|| {
|
|
RelayError::Protocol("Redirect response missing Location header".to_string())
|
|
})?;
|
|
Ok(location)
|
|
} else if status.is_success() {
|
|
// Some relay implementations return the URL in JSON body instead
|
|
let body: serde_json::Value = resp
|
|
.json()
|
|
.await
|
|
.map_err(|e| RelayError::Protocol(e.to_string()))?;
|
|
body.get("auth_url")
|
|
.or_else(|| body.get("url"))
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string())
|
|
.ok_or_else(|| RelayError::Protocol("Response missing auth_url field".to_string()))
|
|
} else {
|
|
let body = resp.text().await.unwrap_or_default();
|
|
Err(RelayError::Api {
|
|
status: status.as_u16(),
|
|
message: body,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Register a pending approval and return the opaque approval token.
|
|
///
|
|
/// Calls `POST /approvals` with the target team/channel/request identifiers.
|
|
/// The returned token is embedded in Slack button values instead of routing fields.
|
|
/// The relay derives the authorized approver from the connection's authed_user_id.
|
|
pub async fn create_approval(
|
|
&self,
|
|
team_id: &str,
|
|
channel_id: &str,
|
|
thread_ts: Option<&str>,
|
|
request_id: &str,
|
|
) -> Result<String, RelayError> {
|
|
let mut body = serde_json::json!({
|
|
"team_id": team_id,
|
|
"channel_id": channel_id,
|
|
"request_id": request_id,
|
|
});
|
|
if let Some(ts) = thread_ts {
|
|
body["thread_ts"] = serde_json::Value::String(ts.to_string());
|
|
}
|
|
|
|
let resp = self
|
|
.http
|
|
.post(format!("{}/approvals", self.base_url))
|
|
.bearer_auth(self.api_key.expose_secret())
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| RelayError::Network(e.to_string()))?;
|
|
|
|
if !resp.status().is_success() {
|
|
let status = resp.status().as_u16();
|
|
let body = resp.text().await.unwrap_or_default();
|
|
return Err(RelayError::Api {
|
|
status,
|
|
message: body,
|
|
});
|
|
}
|
|
|
|
let result: serde_json::Value = resp
|
|
.json()
|
|
.await
|
|
.map_err(|e| RelayError::Protocol(e.to_string()))?;
|
|
|
|
result
|
|
.get("approval_token")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string())
|
|
.ok_or_else(|| RelayError::Protocol("missing approval_token in response".to_string()))
|
|
}
|
|
|
|
pub async fn proxy_provider(
|
|
&self,
|
|
provider: &str,
|
|
team_id: &str,
|
|
method: &str,
|
|
body: serde_json::Value,
|
|
) -> Result<serde_json::Value, RelayError> {
|
|
let url = format!("{}/proxy/{}/{}", self.base_url, provider, method);
|
|
tracing::trace!(
|
|
relay_url = %url,
|
|
provider = %provider,
|
|
method = %method,
|
|
"RelayClient::proxy_provider: sending request"
|
|
);
|
|
let query: Vec<(&str, &str)> = vec![("team_id", team_id)];
|
|
let resp = self
|
|
.http
|
|
.post(&url)
|
|
.bearer_auth(self.api_key.expose_secret())
|
|
.query(&query)
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::warn!(
|
|
relay_url = %url,
|
|
error = %e,
|
|
"RelayClient::proxy_provider: network request failed"
|
|
);
|
|
RelayError::Network(e.to_string())
|
|
})?;
|
|
|
|
if !resp.status().is_success() {
|
|
let status = resp.status().as_u16();
|
|
let body = resp.text().await.unwrap_or_default();
|
|
tracing::warn!(
|
|
relay_url = %url,
|
|
status = status,
|
|
"RelayClient::proxy_provider: channel-relay returned error"
|
|
);
|
|
return Err(RelayError::Api {
|
|
status,
|
|
message: body,
|
|
});
|
|
}
|
|
|
|
resp.json()
|
|
.await
|
|
.map_err(|e| RelayError::Protocol(e.to_string()))
|
|
}
|
|
|
|
/// Fetch the per-instance callback signing secret from channel-relay.
|
|
///
|
|
/// Calls `GET /relay/signing-secret` (authenticated) and returns the decoded
|
|
/// 32-byte secret. Called once at activation time; the result is cached in the
|
|
/// extension manager so subsequent calls to `relay_signing_secret()` use it.
|
|
pub async fn get_signing_secret(&self, team_id: &str) -> Result<Vec<u8>, RelayError> {
|
|
let url = format!("{}/relay/signing-secret", self.base_url);
|
|
tracing::trace!(
|
|
relay_url = %url,
|
|
"RelayClient::get_signing_secret: fetching signing secret"
|
|
);
|
|
let resp = self
|
|
.http
|
|
.get(&url)
|
|
.bearer_auth(self.api_key.expose_secret())
|
|
.query(&[("team_id", team_id)])
|
|
.send()
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::warn!(
|
|
relay_url = %url,
|
|
error = %e,
|
|
"RelayClient::get_signing_secret: network request failed"
|
|
);
|
|
RelayError::Network(e.to_string())
|
|
})?;
|
|
|
|
if !resp.status().is_success() {
|
|
let status = resp.status().as_u16();
|
|
let body = resp.text().await.unwrap_or_default();
|
|
tracing::warn!(
|
|
relay_url = %url,
|
|
status = status,
|
|
body = %body,
|
|
"RelayClient::get_signing_secret: channel-relay returned error"
|
|
);
|
|
return Err(RelayError::Api {
|
|
status,
|
|
message: body,
|
|
});
|
|
}
|
|
tracing::trace!(
|
|
relay_url = %url,
|
|
"RelayClient::get_signing_secret: received successful response"
|
|
);
|
|
|
|
let body: serde_json::Value = resp
|
|
.json()
|
|
.await
|
|
.map_err(|e| RelayError::Protocol(e.to_string()))?;
|
|
|
|
body.get("signing_secret")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| RelayError::Protocol("missing signing_secret in response".to_string()))
|
|
.and_then(|raw| {
|
|
let decoded = hex::decode(raw).map_err(|e| {
|
|
RelayError::Protocol(format!("invalid signing_secret hex: {e}"))
|
|
})?;
|
|
if decoded.len() != 32 {
|
|
return Err(RelayError::Protocol(format!(
|
|
"invalid signing_secret length: expected 32 bytes, got {}",
|
|
decoded.len()
|
|
)));
|
|
}
|
|
Ok(decoded)
|
|
})
|
|
}
|
|
|
|
/// List active connections for an instance.
|
|
pub async fn list_connections(&self, instance_id: &str) -> Result<Vec<Connection>, RelayError> {
|
|
let resp = self
|
|
.http
|
|
.get(format!("{}/connections", self.base_url))
|
|
.bearer_auth(self.api_key.expose_secret())
|
|
.query(&[("instance_id", instance_id)])
|
|
.send()
|
|
.await
|
|
.map_err(|e| RelayError::Network(e.to_string()))?;
|
|
|
|
if !resp.status().is_success() {
|
|
let status = resp.status().as_u16();
|
|
let body = resp.text().await.unwrap_or_default();
|
|
return Err(RelayError::Api {
|
|
status,
|
|
message: body,
|
|
});
|
|
}
|
|
|
|
resp.json()
|
|
.await
|
|
.map_err(|e| RelayError::Protocol(e.to_string()))
|
|
}
|
|
}
|
|
|
|
/// Errors from relay client operations.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum RelayError {
|
|
#[error("Network error: {0}")]
|
|
Network(String),
|
|
|
|
#[error("API error (HTTP {status}): {message}")]
|
|
Api { status: u16, message: String },
|
|
|
|
#[error("Protocol error: {0}")]
|
|
Protocol(String),
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn channel_event_deserialize_minimal() {
|
|
let json = r#"{"event_type": "message", "content": "hello"}"#;
|
|
let event: ChannelEvent = serde_json::from_str(json).expect("parse failed");
|
|
assert_eq!(event.event_type, "message");
|
|
assert_eq!(event.text(), "hello");
|
|
assert!(event.provider_scope.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn channel_event_deserialize_relay_format() {
|
|
// Matches the actual channel-relay ChannelEvent serialization format.
|
|
let json = r#"{
|
|
"id": "evt_123",
|
|
"event_type": "direct_message",
|
|
"provider": "slack",
|
|
"provider_scope": "T123",
|
|
"channel_id": "D456",
|
|
"sender_id": "U789",
|
|
"sender_name": "bob",
|
|
"content": "hi there",
|
|
"thread_id": "1234567890.123456",
|
|
"raw": {},
|
|
"timestamp": "2026-03-09T21:00:00Z"
|
|
}"#;
|
|
let event: ChannelEvent = serde_json::from_str(json).expect("parse failed");
|
|
assert_eq!(event.provider, "slack");
|
|
assert_eq!(event.team_id(), "T123");
|
|
assert_eq!(event.display_name(), "bob");
|
|
assert_eq!(event.thread_id, Some("1234567890.123456".to_string()));
|
|
assert!(event.is_message());
|
|
}
|
|
|
|
#[test]
|
|
fn channel_event_is_message() {
|
|
let make = |et: &str| ChannelEvent {
|
|
id: String::new(),
|
|
event_type: et.to_string(),
|
|
provider: String::new(),
|
|
provider_scope: String::new(),
|
|
channel_id: String::new(),
|
|
sender_id: String::new(),
|
|
sender_name: None,
|
|
content: None,
|
|
thread_id: None,
|
|
raw: serde_json::Value::Null,
|
|
timestamp: None,
|
|
};
|
|
assert!(make("message").is_message());
|
|
assert!(make("direct_message").is_message());
|
|
assert!(make("mention").is_message());
|
|
assert!(!make("reaction").is_message());
|
|
}
|
|
|
|
#[test]
|
|
fn connection_deserialize() {
|
|
let json = r#"{"provider": "slack", "team_id": "T123", "team_name": "My Team", "connected": true}"#;
|
|
let conn: Connection = serde_json::from_str(json).expect("parse failed");
|
|
assert_eq!(conn.provider, "slack");
|
|
assert!(conn.connected);
|
|
}
|
|
|
|
#[test]
|
|
fn relay_error_display() {
|
|
let err = RelayError::Network("timeout".into());
|
|
assert_eq!(err.to_string(), "Network error: timeout");
|
|
|
|
let err = RelayError::Api {
|
|
status: 401,
|
|
message: "unauthorized".into(),
|
|
};
|
|
assert_eq!(err.to_string(), "API error (HTTP 401): unauthorized");
|
|
}
|
|
|
|
#[test]
|
|
fn event_type_constants_match_is_message() {
|
|
let make = |et: &str| ChannelEvent {
|
|
id: String::new(),
|
|
event_type: et.to_string(),
|
|
provider: String::new(),
|
|
provider_scope: String::new(),
|
|
channel_id: String::new(),
|
|
sender_id: String::new(),
|
|
sender_name: None,
|
|
content: None,
|
|
thread_id: None,
|
|
raw: serde_json::Value::Null,
|
|
timestamp: None,
|
|
};
|
|
assert!(make(event_types::MESSAGE).is_message());
|
|
assert!(make(event_types::DIRECT_MESSAGE).is_message());
|
|
assert!(make(event_types::MENTION).is_message());
|
|
}
|
|
}
|