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