use std::time::Duration;
use anyhow::{anyhow, bail, Result};
use serde::Deserialize;
use serde_json::Value;
use super::host::{host, host_opt};
const DEFAULT_RELAY_BASE_ENV: &str = "RYU_BACKEND_URL";
const DEFAULT_RELAY_BASE: &str = "http://localhost:3000";
const BACKOFF_START: Duration = Duration::from_secs(1);
const BACKOFF_MAX: Duration = Duration::from_secs(60);
const SEEN_DELIVERY_CAPACITY: usize = 512;
#[derive(Debug, Default)]
pub struct SeenDeliveries {
order: std::collections::VecDeque<String>,
set: std::collections::HashSet<String>,
}
impl SeenDeliveries {
fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, id: &str) -> bool {
if id.is_empty() {
return true;
}
if self.set.contains(id) {
return false;
}
self.set.insert(id.to_owned());
self.order.push_back(id.to_owned());
while self.order.len() > SEEN_DELIVERY_CAPACITY {
if let Some(evicted) = self.order.pop_front() {
self.set.remove(&evicted);
}
}
true
}
}
fn relay_state_path() -> Option<std::path::PathBuf> {
Some(host_opt()?.data_dir().join("relay.json"))
}
#[derive(Debug, Clone, PartialEq)]
pub enum RelayFrame {
Webhook { delivery_id: String, payload: Value },
Inbound {
delivery_id: String,
path: String,
signature: Option<String>,
body: String,
},
Ping,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type")]
enum WireFrame {
#[serde(rename = "composio.webhook")]
Webhook {
#[serde(default)]
delivery_id: String,
#[serde(default)]
payload: Value,
},
#[serde(rename = "webhook.inbound")]
Inbound {
#[serde(default)]
delivery_id: String,
path: String,
#[serde(default)]
signature: Option<String>,
#[serde(default)]
body: String,
},
#[serde(rename = "ping")]
Ping,
}
pub fn parse_frame(data: &str) -> Option<RelayFrame> {
let wire: WireFrame = serde_json::from_str(data.trim()).ok()?;
Some(match wire {
WireFrame::Webhook {
delivery_id,
payload,
} => RelayFrame::Webhook {
delivery_id,
payload,
},
WireFrame::Inbound {
delivery_id,
path,
signature,
body,
} => RelayFrame::Inbound {
delivery_id,
path,
signature,
body,
},
WireFrame::Ping => RelayFrame::Ping,
})
}
fn node_name() -> String {
if let Ok(name) = std::env::var("RYU_NODE_NAME") {
let trimmed = name.trim();
if !trimmed.is_empty() {
return trimmed.to_owned();
}
}
hostname_or_default()
}
fn hostname_or_default() -> String {
std::env::var("COMPUTERNAME")
.ok()
.or_else(|| std::env::var("HOSTNAME").ok())
.map(|h| h.trim().to_owned())
.filter(|h| !h.is_empty())
.unwrap_or_else(|| "default".to_owned())
}
fn relay_base() -> String {
std::env::var(DEFAULT_RELAY_BASE_ENV)
.ok()
.map(|v| v.trim().trim_end_matches('/').to_owned())
.filter(|v| !v.is_empty())
.unwrap_or_else(|| DEFAULT_RELAY_BASE.to_owned())
}
fn persist_relay_token(relay_token: &str, node: &str) {
let Some(path) = relay_state_path() else {
tracing::warn!("ryu-relay: no host/data-dir; could not persist relay token");
return;
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let data = serde_json::json!({ "relay_token": relay_token, "node_name": node });
if let Err(e) = std::fs::write(&path, data.to_string()) {
tracing::warn!("ryu-relay: could not persist relay token ({e})");
}
}
#[derive(Debug, Deserialize)]
struct RegisterResponse {
relay_token: String,
public_url: String,
}
fn persisted_relay_token() -> Option<String> {
let raw = std::fs::read_to_string(relay_state_path()?).ok()?;
let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
v.get("relay_token")
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
.filter(|t| !t.trim().is_empty())
}
pub fn relay_inbound_url(path: &str) -> Option<String> {
let token = persisted_relay_token()?;
let base = relay_base();
let trimmed = path.trim_start_matches('/');
Some(format!("{base}/api/composio-relay/inbound/{token}/{trimmed}"))
}
async fn register(
client: &reqwest::Client,
base: &str,
token: &str,
node: &str,
) -> Result<(String, String)> {
let url = format!("{base}/api/composio-relay/register");
let resp = client
.post(&url)
.bearer_auth(token)
.json(&serde_json::json!({ "node_name": node }))
.timeout(Duration::from_secs(20))
.send()
.await
.map_err(|e| anyhow!("relay register request failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
bail!("relay register {status}");
}
let body: RegisterResponse = resp
.json()
.await
.map_err(|e| anyhow!("relay register: bad response ({e})"))?;
Ok((body.relay_token, body.public_url))
}
pub async fn start() -> Result<()> {
let host = host()?;
if !host.composio_is_configured() {
bail!("ryu-relay ingress: no Composio key configured — relay not started (opt-in by use)");
}
let token = host
.auth_token()
.ok_or_else(|| anyhow!("ryu-relay ingress: not logged in (no ~/.ryu/auth.json token)"))?;
let base = relay_base();
let node = node_name();
let client = reqwest::Client::new();
let (relay_token, public_url) = register(&client, &base, &token, &node).await?;
persist_relay_token(&relay_token, &node);
super::set_public_url(Some(public_url));
tokio::spawn(async move {
subscribe_loop(client, base, token, node).await;
});
Ok(())
}
async fn subscribe_loop(client: reqwest::Client, base: String, token: String, node: String) {
let mut backoff = BACKOFF_START;
let mut last_event_id: Option<String> = None;
let mut seen = SeenDeliveries::new();
loop {
if let Err(e) =
run_subscription(&client, &base, &token, &node, &mut last_event_id, &mut seen).await
{
tracing::warn!("ryu-relay: subscription dropped ({e}); retrying in {backoff:?}");
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(BACKOFF_MAX);
continue;
}
backoff = BACKOFF_START;
tokio::time::sleep(backoff).await;
}
}
async fn run_subscription(
client: &reqwest::Client,
base: &str,
token: &str,
node: &str,
last_event_id: &mut Option<String>,
seen: &mut SeenDeliveries,
) -> Result<()> {
use futures_util::StreamExt;
let url = format!("{base}/api/composio-relay/subscribe?node_name={node}");
let mut req = client
.get(&url)
.bearer_auth(token)
.header("accept", "text/event-stream");
if let Some(id) = last_event_id.as_deref() {
req = req.header("last-event-id", id);
}
let resp = req
.send()
.await
.map_err(|e| anyhow!("relay subscribe connect failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
bail!("relay subscribe {status}");
}
let mut buf = String::new();
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| anyhow!("relay subscribe stream error: {e}"))?;
buf.push_str(&String::from_utf8_lossy(&chunk));
while let Some(idx) = buf.find("\n\n") {
let raw_event: String = buf.drain(..idx + 2).collect();
if let Some((id, data)) = parse_sse_event(&raw_event) {
dispatch_frame(&data, seen).await;
if let Some(id) = id {
*last_event_id = Some(id);
}
}
}
}
Ok(())
}
fn parse_sse_event(raw: &str) -> Option<(Option<String>, String)> {
let mut id: Option<String> = None;
let mut data = String::new();
for line in raw.lines() {
let line = line.trim_end_matches('\r');
if let Some(rest) = line.strip_prefix("id:") {
id = Some(rest.trim().to_owned());
} else if let Some(rest) = line.strip_prefix("data:") {
if !data.is_empty() {
data.push('\n');
}
data.push_str(rest.strip_prefix(' ').unwrap_or(rest));
}
}
if data.is_empty() {
return None;
}
Some((id, data))
}
async fn dispatch_frame(data: &str, seen: &mut SeenDeliveries) {
match parse_frame(data) {
Some(RelayFrame::Webhook {
delivery_id,
payload,
}) => {
if !seen.insert(&delivery_id) {
tracing::debug!("ryu-relay: skipping duplicate delivery {delivery_id}");
return;
}
let Ok(host) = host() else {
tracing::warn!("ryu-relay: host not initialised; dropping webhook");
return;
};
match host.composio_handle_webhook(&payload).await {
Some(fired) => {
tracing::info!("ryu-relay: dispatched webhook, fired {fired} agent run(s)");
super::record_delivery(super::WEBHOOK_PATH);
}
None => tracing::warn!(
"ryu-relay: composio-triggers store not initialised; dropping webhook"
),
}
}
Some(RelayFrame::Inbound {
delivery_id,
path,
signature,
body,
}) => {
if !seen.insert(&delivery_id) {
tracing::debug!("ryu-relay: skipping duplicate delivery {delivery_id}");
return;
}
let outcome =
super::deliver_inbound(&path, body.as_bytes(), signature.as_deref()).await;
match outcome {
super::InboundOutcome::Delivered { detail } => {
tracing::info!("ryu-relay: delivered inbound to {path}: {detail}");
}
super::InboundOutcome::Rejected(reason) => {
tracing::warn!("ryu-relay: rejected inbound to {path}: {reason}");
}
super::InboundOutcome::Unhandled => {
tracing::warn!("ryu-relay: no handler for inbound path {path}; dropping");
}
}
}
Some(RelayFrame::Ping) | None => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_frame_yields_webhook_struct() {
let data = r#"{"type":"composio.webhook","delivery_id":"dlv-1","payload":{"trigger_slug":"SLACK_MESSAGE"}}"#;
match parse_frame(data) {
Some(RelayFrame::Webhook {
delivery_id,
payload,
}) => {
assert_eq!(delivery_id, "dlv-1");
assert_eq!(payload["trigger_slug"], "SLACK_MESSAGE");
}
other => panic!("expected webhook frame, got {other:?}"),
}
}
#[test]
fn parse_frame_ignores_ping() {
assert_eq!(parse_frame(r#"{"type":"ping"}"#), Some(RelayFrame::Ping));
}
#[test]
fn parse_frame_rejects_unknown() {
assert_eq!(parse_frame(r#"{"type":"other"}"#), None);
assert_eq!(parse_frame("not json"), None);
}
#[test]
fn parse_sse_event_extracts_id_and_data() {
let raw = "id: 7\ndata: {\"type\":\"ping\"}\n\n";
let (id, data) = parse_sse_event(raw).unwrap();
assert_eq!(id.as_deref(), Some("7"));
assert_eq!(data, "{\"type\":\"ping\"}");
}
#[test]
fn parse_sse_event_concatenates_multiline_data() {
let raw = "data: line1\ndata: line2\n\n";
let (_, data) = parse_sse_event(raw).unwrap();
assert_eq!(data, "line1\nline2");
}
#[test]
fn parse_sse_event_none_without_data() {
assert!(parse_sse_event(": comment\n\n").is_none());
}
#[test]
fn node_name_resolves_a_nonempty_string() {
assert!(!node_name().is_empty());
}
#[test]
fn seen_deliveries_dedups_repeats() {
let mut seen = SeenDeliveries::new();
assert!(seen.insert("dlv-1"), "first sight is new");
assert!(!seen.insert("dlv-1"), "second sight is a duplicate");
assert!(seen.insert("dlv-2"), "a different id is new");
}
#[test]
fn seen_deliveries_empty_id_always_new() {
let mut seen = SeenDeliveries::new();
assert!(seen.insert(""));
assert!(seen.insert(""));
}
#[test]
fn seen_deliveries_evicts_oldest_when_full() {
let mut seen = SeenDeliveries::new();
for i in 0..SEEN_DELIVERY_CAPACITY {
assert!(seen.insert(&format!("dlv-{i}")));
}
assert!(seen.insert("overflow"));
assert!(
seen.insert("dlv-0"),
"oldest was evicted, so it is new again"
);
assert!(!seen.insert("overflow"));
}
#[test]
fn relay_base_strips_trailing_slash() {
if std::env::var(DEFAULT_RELAY_BASE_ENV).is_err() {
assert_eq!(relay_base(), DEFAULT_RELAY_BASE);
assert!(!relay_base().ends_with('/'));
}
}
}