systemprompt_agent/services/a2a_server/streaming/
webhook_client.rs1use std::sync::{Arc, OnceLock};
15
16use async_trait::async_trait;
17use reqwest::Client;
18use serde::Serialize;
19use systemprompt_identifiers::UserId;
20use systemprompt_models::{A2AEvent, AgUiEvent, Config};
21
22#[derive(Debug, thiserror::Error)]
23pub enum WebhookError {
24 #[error("HTTP request failed: {0}")]
25 Request(#[from] reqwest::Error),
26 #[error("Webhook returned error status {status}: {message}")]
27 StatusError { status: u16, message: String },
28}
29
30#[async_trait]
34pub trait WebhookBroadcaster: Send + Sync + std::fmt::Debug {
35 async fn broadcast_agui(
36 &self,
37 user_id: &UserId,
38 event: AgUiEvent,
39 auth_token: &str,
40 ) -> Result<usize, WebhookError>;
41
42 async fn broadcast_a2a(
43 &self,
44 user_id: &UserId,
45 event: A2AEvent,
46 auth_token: &str,
47 ) -> Result<usize, WebhookError>;
48}
49
50#[derive(Serialize)]
51struct AgUiWebhookPayload {
52 #[serde(flatten)]
53 event: AgUiEvent,
54 user_id: UserId,
55}
56
57#[derive(Serialize)]
58struct A2AWebhookPayload {
59 #[serde(flatten)]
60 event: A2AEvent,
61 user_id: UserId,
62}
63
64fn get_api_url() -> String {
65 Config::get().map_or_else(
66 |_| "http://localhost:3000".to_owned(),
67 |c| c.api_internal_url.clone(),
68 )
69}
70
71#[derive(Debug, Default, Clone, Copy)]
72pub struct HttpWebhookBroadcaster;
73
74#[async_trait]
75impl WebhookBroadcaster for HttpWebhookBroadcaster {
76 async fn broadcast_agui(
77 &self,
78 user_id: &UserId,
79 event: AgUiEvent,
80 auth_token: &str,
81 ) -> Result<usize, WebhookError> {
82 let url = format!("{}/api/v1/webhook/agui", get_api_url());
83 let event_type = event.event_type();
84 if auth_token.is_empty() {
85 tracing::warn!(
86 event_type = ?event_type,
87 user_id = %user_id,
88 "AGUI broadcast with empty auth_token"
89 );
90 }
91 let payload = AgUiWebhookPayload {
92 event,
93 user_id: user_id.clone(),
94 };
95 post_and_decode(&url, auth_token, &payload, "AGUI").await
96 }
97
98 async fn broadcast_a2a(
99 &self,
100 user_id: &UserId,
101 event: A2AEvent,
102 auth_token: &str,
103 ) -> Result<usize, WebhookError> {
104 let url = format!("{}/api/v1/webhook/a2a", get_api_url());
105 let payload = A2AWebhookPayload {
106 event,
107 user_id: user_id.clone(),
108 };
109 post_and_decode(&url, auth_token, &payload, "A2A").await
110 }
111}
112
113#[derive(serde::Deserialize)]
114struct WebhookResponse {
115 connection_count: usize,
116}
117
118async fn post_and_decode<T: Serialize + Sync + ?Sized>(
119 url: &str,
120 auth_token: &str,
121 payload: &T,
122 kind: &str,
123) -> Result<usize, WebhookError> {
124 let client = Client::new();
125 let response = client
126 .post(url)
127 .header("Authorization", format!("Bearer {auth_token}"))
128 .header("Content-Type", "application/json")
129 .json(payload)
130 .send()
131 .await;
132 match response {
133 Ok(resp) if resp.status().is_success() => match resp.json::<WebhookResponse>().await {
134 Ok(r) => {
135 tracing::debug!(
136 kind = kind,
137 connection_count = r.connection_count,
138 "broadcasted"
139 );
140 Ok(r.connection_count)
141 },
142 Err(e) => {
143 tracing::error!(kind = kind, error = %e, "response parse error");
144 Err(WebhookError::Request(e))
145 },
146 },
147 Ok(resp) => {
148 let status = resp.status().as_u16();
149 let message = resp
150 .text()
151 .await
152 .unwrap_or_else(|e| format!("<error reading response: {e}>"));
153 tracing::error!(kind = kind, status, message = %message, "event failed");
154 Err(WebhookError::StatusError { status, message })
155 },
156 Err(e) => {
157 tracing::error!(kind = kind, error = %e, "request error");
158 Err(WebhookError::Request(e))
159 },
160 }
161}
162
163static GLOBAL_BROADCASTER: OnceLock<Arc<dyn WebhookBroadcaster>> = OnceLock::new();
164
165fn default_broadcaster() -> Arc<dyn WebhookBroadcaster> {
166 Arc::new(HttpWebhookBroadcaster)
167}
168
169fn active_broadcaster() -> Arc<dyn WebhookBroadcaster> {
170 Arc::clone(GLOBAL_BROADCASTER.get_or_init(default_broadcaster))
171}
172
173pub fn install_for_test(broadcaster: Arc<dyn WebhookBroadcaster>) {
174 drop(GLOBAL_BROADCASTER.set(broadcaster));
175}
176
177pub async fn broadcast_agui_event(
178 user_id: &UserId,
179 event: AgUiEvent,
180 auth_token: &str,
181) -> Result<usize, WebhookError> {
182 active_broadcaster()
183 .broadcast_agui(user_id, event, auth_token)
184 .await
185}
186
187pub async fn broadcast_a2a_event(
188 user_id: &UserId,
189 event: A2AEvent,
190 auth_token: &str,
191) -> Result<usize, WebhookError> {
192 active_broadcaster()
193 .broadcast_a2a(user_id, event, auth_token)
194 .await
195}
196
197#[derive(Clone, Debug)]
198pub struct WebhookContext {
199 user_id: UserId,
200 auth_token: String,
201}
202
203impl WebhookContext {
204 pub fn new(user_id: UserId, auth_token: impl Into<String>) -> Self {
205 Self {
206 user_id,
207 auth_token: auth_token.into(),
208 }
209 }
210
211 pub const fn user_id(&self) -> &UserId {
212 &self.user_id
213 }
214
215 pub async fn broadcast_agui(&self, event: AgUiEvent) -> Result<usize, WebhookError> {
216 broadcast_agui_event(&self.user_id, event, &self.auth_token).await
217 }
218
219 pub async fn broadcast_a2a(&self, event: A2AEvent) -> Result<usize, WebhookError> {
220 broadcast_a2a_event(&self.user_id, event, &self.auth_token).await
221 }
222}