use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use tracing::{debug, error, info, warn};
struct JwtCache {
token: String,
expires_at: Instant,
}
#[derive(Clone)]
pub struct PushRelayClient {
relay_url: String,
http_client: reqwest::Client,
debounce: Arc<RwLock<HashMap<String, Instant>>>,
cooldown: Duration,
token_url: Option<String>,
client_id: Option<String>,
client_secret: Option<String>,
jwt_cache: Arc<RwLock<Option<JwtCache>>>,
}
#[derive(Debug, Serialize)]
struct RegisterRequest {
device_token: String,
platform: String,
#[serde(skip_serializing_if = "Option::is_none")]
bundle_id: Option<String>,
}
#[derive(Debug, Serialize)]
struct UnregisterRequest {
device_token: String,
}
#[derive(Debug, Serialize)]
struct PushRequest {
title: String,
body: String,
}
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
expires_in: u64,
}
#[derive(Debug, Deserialize)]
struct RelayResponse {
ok: bool,
#[serde(default)]
error: Option<String>,
#[serde(default)]
message: Option<String>,
}
impl PushRelayClient {
pub fn new(relay_url: String, _relay_token: String) -> Self {
let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.expect("Failed to create HTTP client");
Self {
relay_url: relay_url.trim_end_matches('/').to_string(),
http_client,
debounce: Arc::new(RwLock::new(HashMap::new())),
cooldown: Duration::from_secs(30),
token_url: None,
client_id: None,
client_secret: None,
jwt_cache: Arc::new(RwLock::new(None)),
}
}
pub fn with_jwt_credentials(
mut self,
token_url: String,
client_id: String,
client_secret: String,
) -> Self {
self.token_url = Some(token_url);
self.client_id = Some(client_id);
self.client_secret = Some(client_secret);
self
}
async fn get_jwt(&self) -> Result<String> {
{
let cache = self.jwt_cache.read().await;
if let Some(ref c) = *cache {
if c.expires_at > Instant::now() + Duration::from_secs(60) {
return Ok(c.token.clone());
}
}
}
let token_url = self
.token_url
.as_deref()
.context("token_url not configured")?;
let client_id = self
.client_id
.as_deref()
.context("client_id not configured")?;
let client_secret = self
.client_secret
.as_deref()
.context("client_secret not configured")?;
let url = format!("{}/token", token_url.trim_end_matches('/'));
let body = serde_json::json!({
"client_id": client_id,
"client_secret": client_secret,
});
debug!("Fetching JWT from token service: {}", url);
let res = self
.http_client
.post(&url)
.json(&body)
.send()
.await
.context("Failed to contact token service")?;
let status = res.status();
if !status.is_success() {
anyhow::bail!("Token service returned HTTP {}", status);
}
let token_resp: TokenResponse = res
.json()
.await
.context("Failed to parse token service response")?;
let expires_at = Instant::now()
+ Duration::from_secs(token_resp.expires_in.saturating_sub(60));
let mut cache = self.jwt_cache.write().await;
*cache = Some(JwtCache {
token: token_resp.access_token.clone(),
expires_at,
});
debug!("JWT fetched, expires in {}s", token_resp.expires_in);
Ok(token_resp.access_token)
}
async fn authorized_request(
&self,
builder: reqwest::RequestBuilder,
) -> Result<reqwest::RequestBuilder> {
let jwt = self.get_jwt().await?;
Ok(builder.header("Authorization", format!("Bearer {}", jwt)))
}
pub async fn register_device(
&self,
device_token: &str,
platform: &str,
bundle_id: Option<&str>,
) -> Result<()> {
let url = format!("{}/register", self.relay_url);
let body = RegisterRequest {
device_token: device_token.to_string(),
platform: platform.to_string(),
bundle_id: bundle_id.map(|s| s.to_string()),
};
info!("📱 Registering {} device token with push relay", platform);
debug!("Push relay URL: {}", url);
let builder = self.http_client.post(&url).json(&body);
let builder = self.authorized_request(builder).await?;
let res = builder
.send()
.await
.context("Failed to contact push relay for registration")?;
let status = res.status();
let response: RelayResponse = res
.json()
.await
.context("Failed to parse push relay response")?;
if response.ok {
info!("✅ Device token registered with push relay");
Ok(())
} else {
let err_msg = response
.error
.or(response.message)
.unwrap_or_else(|| format!("HTTP {}", status));
error!("❌ Push relay registration failed: {}", err_msg);
anyhow::bail!("Push relay registration failed: {}", err_msg)
}
}
pub async fn unregister_device(&self, device_token: &str) -> Result<()> {
let url = format!("{}/register", self.relay_url);
let body = UnregisterRequest {
device_token: device_token.to_string(),
};
info!("📱 Unregistering device token from push relay");
let builder = self.http_client.delete(&url).json(&body);
let builder = self.authorized_request(builder).await?;
let res = builder
.send()
.await
.context("Failed to contact push relay for unregistration")?;
let response: RelayResponse = res
.json()
.await
.context("Failed to parse push relay response")?;
if response.ok {
info!("✅ Device token unregistered from push relay");
}
Ok(())
}
pub async fn notify(&self, agent_name: &str) -> Result<bool> {
let debounce_key = self
.client_id
.clone()
.unwrap_or_else(|| self.relay_url.clone());
{
let debounce = self.debounce.read().await;
if let Some(last) = debounce.get(&debounce_key) {
if last.elapsed() < self.cooldown {
debug!(
"Push notification throttled ({}s remaining)",
(self.cooldown - last.elapsed()).as_secs()
);
return Ok(false);
}
}
}
{
let mut debounce = self.debounce.write().await;
debounce.insert(debounce_key, Instant::now());
}
let url = format!("{}/push", self.relay_url);
let body = PushRequest {
title: agent_name.to_string(),
body: "Your agent has new activity".to_string(),
};
info!("🔔 Sending push notification via relay for agent '{}'", agent_name);
let builder = self.http_client.post(&url).json(&body);
let builder = match self.authorized_request(builder).await {
Ok(b) => b,
Err(e) => {
warn!("⚠️ Failed to get JWT for push notification: {}", e);
return Ok(false);
}
};
let res = builder
.send()
.await
.context("Failed to contact push relay for notification")?;
let status = res.status();
let response: RelayResponse = res
.json()
.await
.context("Failed to parse push relay response")?;
if response.ok {
info!("✅ Push notification sent via relay");
Ok(true)
} else {
let err_msg = response
.error
.or(response.message)
.unwrap_or_else(|| format!("HTTP {}", status));
warn!("⚠️ Push relay notification failed: {}", err_msg);
Ok(false)
}
}
}