use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, thiserror::Error)]
pub enum BeaconError {
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("Relay error: {0}")]
Relay(String),
#[error("JSON parse error: {0}")]
Json(#[from] serde_json::Error),
}
pub type Result<T> = std::result::Result<T, BeaconError>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Registration {
pub ok: bool,
pub agent_id: String,
pub relay_token: String,
pub token_expires: f64,
#[serde(default)]
pub ttl_s: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Heartbeat {
pub ok: bool,
#[serde(default)]
pub beat_count: u64,
#[serde(default)]
pub assessment: String,
#[serde(default)]
pub seo: SeoInfo,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SeoInfo {
#[serde(default)]
pub profile_url: String,
#[serde(default)]
pub dofollow: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SeoStats {
pub agent_id: String,
pub seo_grade: String,
pub seo_score: u32,
pub profiles: ProfileUrls,
pub schema_org: bool,
pub speakable_markup: bool,
pub og_tags: bool,
#[serde(default)]
pub has_custom_seo_url: bool,
#[serde(default)]
pub enhancement_summary: String,
#[serde(default)]
pub recommendation: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileUrls {
pub html: String,
pub json: String,
pub xml: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SeoReport {
pub total_agents: u32,
pub native_agents: u32,
pub relay_agents: u32,
pub agents_with_custom_seo: u32,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscoveredAgent {
pub agent_id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub provider: String,
#[serde(default)]
pub status: String,
#[serde(default)]
pub beat_count: u64,
#[serde(default)]
pub capabilities: Vec<String>,
#[serde(default)]
pub profile_url: String,
#[serde(default)]
pub seo_url: String,
}
pub struct RelayClient {
base_url: String,
http: Client,
}
impl RelayClient {
pub fn new(base_url: &str) -> Self {
Self {
base_url: base_url.trim_end_matches('/').to_string(),
http: Client::builder()
.danger_accept_invalid_certs(true)
.build()
.unwrap_or_default(),
}
}
pub fn register(
&self,
name: &str,
provider: &str,
capabilities: &[&str],
) -> Result<Registration> {
let mut hasher = Sha256::new();
hasher.update(format!("beacon-rust-agent-{name}"));
let seed = hex::encode(hasher.finalize());
let pubkey_hex = &seed[..64];
let payload = serde_json::json!({
"pubkey_hex": pubkey_hex,
"model_id": "rust-native",
"provider": provider,
"capabilities": capabilities,
"name": name,
});
let resp: serde_json::Value = self
.http
.post(format!("{}/beacon/relay/register", self.base_url))
.json(&payload)
.send()?
.json()?;
if resp.get("ok").and_then(|v| v.as_bool()) == Some(true) {
Ok(serde_json::from_value(resp)?)
} else {
Err(BeaconError::Relay(
resp.get("error")
.and_then(|v| v.as_str())
.unwrap_or("unknown error")
.to_string(),
))
}
}
pub fn heartbeat(&self, agent_id: &str, token: &str) -> Result<Heartbeat> {
let payload = serde_json::json!({
"agent_id": agent_id,
"status": "alive",
});
let resp: serde_json::Value = self
.http
.post(format!("{}/beacon/relay/heartbeat", self.base_url))
.bearer_auth(token)
.json(&payload)
.send()?
.json()?;
if resp.get("ok").and_then(|v| v.as_bool()) == Some(true) {
Ok(serde_json::from_value(resp)?)
} else {
Err(BeaconError::Relay(
resp.get("error")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string(),
))
}
}
pub fn heartbeat_seo(
&self,
agent_id: &str,
token: &str,
seo_url: Option<&str>,
seo_description: Option<&str>,
) -> Result<Heartbeat> {
let mut payload = serde_json::json!({
"agent_id": agent_id,
"status": "alive",
});
if let Some(url) = seo_url {
payload["seo_url"] = serde_json::Value::String(url.to_string());
}
if let Some(desc) = seo_description {
payload["seo_description"] = serde_json::Value::String(desc.to_string());
}
let resp: serde_json::Value = self
.http
.post(format!("{}/beacon/relay/heartbeat/seo", self.base_url))
.bearer_auth(token)
.json(&payload)
.send()?
.json()?;
if resp.get("ok").and_then(|v| v.as_bool()) == Some(true) {
Ok(serde_json::from_value(resp)?)
} else {
Err(BeaconError::Relay(
resp.get("error")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string(),
))
}
}
pub fn seo_stats(&self, agent_id: &str) -> Result<SeoStats> {
let resp: SeoStats = self
.http
.get(format!(
"{}/beacon/relay/seo/stats/{agent_id}",
self.base_url
))
.send()?
.json()?;
Ok(resp)
}
pub fn seo_report(&self) -> Result<SeoReport> {
let resp: SeoReport = self
.http
.get(format!("{}/beacon/relay/seo/report", self.base_url))
.send()?
.json()?;
Ok(resp)
}
pub fn discover(&self, include_dead: bool) -> Result<Vec<DiscoveredAgent>> {
let url = if include_dead {
format!(
"{}/beacon/relay/discover?include_dead=true",
self.base_url
)
} else {
format!("{}/beacon/relay/discover", self.base_url)
};
let resp: Vec<DiscoveredAgent> = self.http.get(&url).send()?.json()?;
Ok(resp)
}
pub fn agent_profile_json(&self, agent_id: &str) -> Result<serde_json::Value> {
let resp: serde_json::Value = self
.http
.get(format!(
"{}/beacon/agent/{agent_id}.json",
self.base_url
))
.send()?
.json()?;
Ok(resp)
}
pub fn agent_profile_xml(&self, agent_id: &str) -> Result<String> {
let resp = self
.http
.get(format!(
"{}/beacon/agent/{agent_id}.xml",
self.base_url
))
.send()?
.text()?;
Ok(resp)
}
pub fn llms_txt(&self) -> Result<String> {
let resp = self
.http
.get(format!("{}/beacon/llms.txt", self.base_url))
.send()?
.text()?;
Ok(resp)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_creation() {
let client = RelayClient::new("https://rustchain.org");
assert_eq!(client.base_url, "https://rustchain.org");
}
#[test]
fn test_client_strips_trailing_slash() {
let client = RelayClient::new("https://rustchain.org/");
assert_eq!(client.base_url, "https://rustchain.org");
}
}