1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6use tokio::sync::RwLock;
7use tracing::{debug, error, info, warn};
8
9struct JwtCache {
11 token: String,
12 expires_at: Instant,
13}
14
15#[derive(Clone)]
21pub struct PushRelayClient {
22 relay_url: String,
23 http_client: reqwest::Client,
24 debounce: Arc<RwLock<HashMap<String, Instant>>>,
26 cooldown: Duration,
28 token_url: Option<String>,
30 client_id: Option<String>,
31 client_secret: Option<String>,
32 jwt_cache: Arc<RwLock<Option<JwtCache>>>,
33}
34
35#[derive(Debug, Serialize)]
37struct RegisterRequest {
38 device_token: String,
39 platform: String,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 bundle_id: Option<String>,
42}
43
44#[derive(Debug, Serialize)]
46struct UnregisterRequest {
47 device_token: String,
48}
49
50#[derive(Debug, Serialize)]
52struct PushRequest {
53 title: String,
54 body: String,
55}
56
57#[derive(Debug, Deserialize)]
59struct TokenResponse {
60 access_token: String,
61 expires_in: u64,
62}
63
64#[derive(Debug, Deserialize)]
66struct RelayResponse {
67 ok: bool,
68 #[serde(default)]
69 error: Option<String>,
70 #[serde(default)]
71 message: Option<String>,
72}
73
74impl PushRelayClient {
75 pub fn new(relay_url: String, _relay_token: String) -> Self {
80 let http_client = reqwest::Client::builder()
81 .timeout(Duration::from_secs(10))
82 .build()
83 .expect("Failed to create HTTP client");
84
85 Self {
86 relay_url: relay_url.trim_end_matches('/').to_string(),
87 http_client,
88 debounce: Arc::new(RwLock::new(HashMap::new())),
89 cooldown: Duration::from_secs(30),
90 token_url: None,
91 client_id: None,
92 client_secret: None,
93 jwt_cache: Arc::new(RwLock::new(None)),
94 }
95 }
96
97 pub fn with_jwt_credentials(
99 mut self,
100 token_url: String,
101 client_id: String,
102 client_secret: String,
103 ) -> Self {
104 self.token_url = Some(token_url);
105 self.client_id = Some(client_id);
106 self.client_secret = Some(client_secret);
107 self
108 }
109
110 async fn get_jwt(&self) -> Result<String> {
114 {
116 let cache = self.jwt_cache.read().await;
117 if let Some(ref c) = *cache {
118 if c.expires_at > Instant::now() + Duration::from_secs(60) {
119 return Ok(c.token.clone());
120 }
121 }
122 }
123
124 let token_url = self
125 .token_url
126 .as_deref()
127 .context("token_url not configured")?;
128 let client_id = self
129 .client_id
130 .as_deref()
131 .context("client_id not configured")?;
132 let client_secret = self
133 .client_secret
134 .as_deref()
135 .context("client_secret not configured")?;
136
137 let url = format!("{}/token", token_url.trim_end_matches('/'));
138 let body = serde_json::json!({
139 "client_id": client_id,
140 "client_secret": client_secret,
141 });
142
143 debug!("Fetching JWT from token service: {}", url);
144 let res = self
145 .http_client
146 .post(&url)
147 .json(&body)
148 .send()
149 .await
150 .context("Failed to contact token service")?;
151
152 let status = res.status();
153 if !status.is_success() {
154 anyhow::bail!("Token service returned HTTP {}", status);
155 }
156
157 let token_resp: TokenResponse = res
158 .json()
159 .await
160 .context("Failed to parse token service response")?;
161
162 let expires_at = Instant::now()
163 + Duration::from_secs(token_resp.expires_in.saturating_sub(60));
164
165 let mut cache = self.jwt_cache.write().await;
166 *cache = Some(JwtCache {
167 token: token_resp.access_token.clone(),
168 expires_at,
169 });
170
171 debug!("JWT fetched, expires in {}s", token_resp.expires_in);
172 Ok(token_resp.access_token)
173 }
174
175 async fn authorized_request(
177 &self,
178 builder: reqwest::RequestBuilder,
179 ) -> Result<reqwest::RequestBuilder> {
180 let jwt = self.get_jwt().await?;
181 Ok(builder.header("Authorization", format!("Bearer {}", jwt)))
182 }
183
184 pub async fn register_device(
188 &self,
189 device_token: &str,
190 platform: &str,
191 bundle_id: Option<&str>,
192 ) -> Result<()> {
193 let url = format!("{}/register", self.relay_url);
194 let body = RegisterRequest {
195 device_token: device_token.to_string(),
196 platform: platform.to_string(),
197 bundle_id: bundle_id.map(|s| s.to_string()),
198 };
199
200 info!("📱 Registering {} device token with push relay", platform);
201 debug!("Push relay URL: {}", url);
202
203 let builder = self.http_client.post(&url).json(&body);
204 let builder = self.authorized_request(builder).await?;
205 let res = builder
206 .send()
207 .await
208 .context("Failed to contact push relay for registration")?;
209
210 let status = res.status();
211 let response: RelayResponse = res
212 .json()
213 .await
214 .context("Failed to parse push relay response")?;
215
216 if response.ok {
217 info!("✅ Device token registered with push relay");
218 Ok(())
219 } else {
220 let err_msg = response
221 .error
222 .or(response.message)
223 .unwrap_or_else(|| format!("HTTP {}", status));
224 error!("❌ Push relay registration failed: {}", err_msg);
225 anyhow::bail!("Push relay registration failed: {}", err_msg)
226 }
227 }
228
229 pub async fn unregister_device(&self, device_token: &str) -> Result<()> {
231 let url = format!("{}/register", self.relay_url);
232 let body = UnregisterRequest {
233 device_token: device_token.to_string(),
234 };
235
236 info!("📱 Unregistering device token from push relay");
237
238 let builder = self.http_client.delete(&url).json(&body);
239 let builder = self.authorized_request(builder).await?;
240 let res = builder
241 .send()
242 .await
243 .context("Failed to contact push relay for unregistration")?;
244
245 let response: RelayResponse = res
246 .json()
247 .await
248 .context("Failed to parse push relay response")?;
249
250 if response.ok {
251 info!("✅ Device token unregistered from push relay");
252 }
253 Ok(())
254 }
255
256 pub async fn notify(&self, agent_name: &str) -> Result<bool> {
264 let debounce_key = self
266 .client_id
267 .clone()
268 .unwrap_or_else(|| self.relay_url.clone());
269
270 {
272 let debounce = self.debounce.read().await;
273 if let Some(last) = debounce.get(&debounce_key) {
274 if last.elapsed() < self.cooldown {
275 debug!(
276 "Push notification throttled ({}s remaining)",
277 (self.cooldown - last.elapsed()).as_secs()
278 );
279 return Ok(false);
280 }
281 }
282 }
283
284 {
286 let mut debounce = self.debounce.write().await;
287 debounce.insert(debounce_key, Instant::now());
288 }
289
290 let url = format!("{}/push", self.relay_url);
291 let body = PushRequest {
292 title: agent_name.to_string(),
293 body: "Your agent has new activity".to_string(),
294 };
295
296 info!("🔔 Sending push notification via relay for agent '{}'", agent_name);
297
298 let builder = self.http_client.post(&url).json(&body);
299 let builder = match self.authorized_request(builder).await {
300 Ok(b) => b,
301 Err(e) => {
302 warn!("⚠️ Failed to get JWT for push notification: {}", e);
303 return Ok(false);
304 }
305 };
306
307 let res = builder
308 .send()
309 .await
310 .context("Failed to contact push relay for notification")?;
311
312 let status = res.status();
313 let response: RelayResponse = res
314 .json()
315 .await
316 .context("Failed to parse push relay response")?;
317
318 if response.ok {
319 info!("✅ Push notification sent via relay");
320 Ok(true)
321 } else {
322 let err_msg = response
323 .error
324 .or(response.message)
325 .unwrap_or_else(|| format!("HTTP {}", status));
326 warn!("⚠️ Push relay notification failed: {}", err_msg);
327 Ok(false)
328 }
329 }
330}