use reqwest::Client;
use serde::Serialize;
use systemprompt_identifiers::UserId;
use systemprompt_models::{A2AEvent, AgUiEvent, Config};
#[derive(Debug, thiserror::Error)]
pub enum WebhookError {
#[error("HTTP request failed: {0}")]
Request(#[from] reqwest::Error),
#[error("Webhook returned error status {status}: {message}")]
StatusError { status: u16, message: String },
}
#[derive(Serialize)]
struct AgUiWebhookPayload {
#[serde(flatten)]
event: AgUiEvent,
user_id: UserId,
}
#[derive(Serialize)]
struct A2AWebhookPayload {
#[serde(flatten)]
event: A2AEvent,
user_id: UserId,
}
fn get_api_url() -> String {
Config::get().map_or_else(
|_| "http://localhost:3000".to_string(),
|c| c.api_internal_url.clone(),
)
}
pub async fn broadcast_agui_event(
user_id: &UserId,
event: AgUiEvent,
auth_token: &str,
) -> Result<usize, WebhookError> {
let url = format!("{}/api/v1/webhook/agui", get_api_url());
let event_type = event.event_type();
if auth_token.is_empty() {
tracing::warn!(
event_type = ?event_type,
user_id = %user_id,
"Attempting to broadcast AGUI event with empty auth_token - webhook will fail"
);
}
tracing::debug!(event_type = ?event_type, url = %url, has_token = !auth_token.is_empty(), "Sending AGUI event");
let payload = AgUiWebhookPayload {
event,
user_id: user_id.clone(),
};
let client = Client::new();
let response = client
.post(&url)
.header("Authorization", format!("Bearer {}", auth_token))
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await;
match response {
Ok(resp) => {
if resp.status().is_success() {
#[derive(serde::Deserialize)]
struct WebhookResponse {
connection_count: usize,
}
match resp.json::<WebhookResponse>().await {
Ok(result) => {
tracing::debug!(
event_type = ?event_type,
connection_count = result.connection_count,
"AGUI event broadcasted"
);
Ok(result.connection_count)
},
Err(e) => {
tracing::error!(
event_type = ?event_type,
error = %e,
"AGUI response parse error"
);
Err(WebhookError::Request(e))
},
}
} else {
let status = resp.status().as_u16();
let message = resp
.text()
.await
.unwrap_or_else(|e| format!("<error reading response: {}>", e));
tracing::error!(
event_type = ?event_type,
status = status,
message = %message,
"AGUI event failed"
);
Err(WebhookError::StatusError { status, message })
}
},
Err(e) => {
tracing::error!(event_type = ?event_type, error = %e, "AGUI request error");
Err(WebhookError::Request(e))
},
}
}
pub async fn broadcast_a2a_event(
user_id: &UserId,
event: A2AEvent,
auth_token: &str,
) -> Result<usize, WebhookError> {
let url = format!("{}/api/v1/webhook/a2a", get_api_url());
let event_type = event.event_type();
tracing::debug!(event_type = ?event_type, url = %url, "Sending A2A event");
let payload = A2AWebhookPayload {
event,
user_id: user_id.clone(),
};
let client = Client::new();
let response = client
.post(&url)
.header("Authorization", format!("Bearer {}", auth_token))
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await;
match response {
Ok(resp) => {
if resp.status().is_success() {
#[derive(serde::Deserialize)]
struct WebhookResponse {
connection_count: usize,
}
match resp.json::<WebhookResponse>().await {
Ok(result) => {
tracing::debug!(
event_type = ?event_type,
connection_count = result.connection_count,
"A2A event broadcasted"
);
Ok(result.connection_count)
},
Err(e) => {
tracing::error!(
event_type = ?event_type,
error = %e,
"A2A response parse error"
);
Err(WebhookError::Request(e))
},
}
} else {
let status = resp.status().as_u16();
let message = resp
.text()
.await
.unwrap_or_else(|e| format!("<error reading response: {}>", e));
tracing::error!(
event_type = ?event_type,
status = status,
message = %message,
"A2A event failed"
);
Err(WebhookError::StatusError { status, message })
}
},
Err(e) => {
tracing::error!(event_type = ?event_type, error = %e, "A2A request error");
Err(WebhookError::Request(e))
},
}
}
#[derive(Clone, Debug)]
pub struct WebhookContext {
user_id: UserId,
auth_token: String,
}
impl WebhookContext {
pub fn new(user_id: UserId, auth_token: impl Into<String>) -> Self {
Self {
user_id,
auth_token: auth_token.into(),
}
}
pub const fn user_id(&self) -> &UserId {
&self.user_id
}
pub async fn broadcast_agui(&self, event: AgUiEvent) -> Result<usize, WebhookError> {
broadcast_agui_event(&self.user_id, event, &self.auth_token).await
}
pub async fn broadcast_a2a(&self, event: A2AEvent) -> Result<usize, WebhookError> {
broadcast_a2a_event(&self.user_id, event, &self.auth_token).await
}
}