1use reqwest::blocking::Client;
45use serde::{Deserialize, Serialize};
46use sha2::{Digest, Sha256};
47
48#[derive(Debug, thiserror::Error)]
50pub enum BeaconError {
51 #[error("HTTP request failed: {0}")]
52 Http(#[from] reqwest::Error),
53 #[error("Relay error: {0}")]
54 Relay(String),
55 #[error("JSON parse error: {0}")]
56 Json(#[from] serde_json::Error),
57}
58
59pub type Result<T> = std::result::Result<T, BeaconError>;
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct Registration {
66 pub ok: bool,
67 pub agent_id: String,
68 pub relay_token: String,
69 pub token_expires: f64,
70 #[serde(default)]
71 pub ttl_s: u64,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct Heartbeat {
77 pub ok: bool,
78 #[serde(default)]
79 pub beat_count: u64,
80 #[serde(default)]
81 pub assessment: String,
82 #[serde(default)]
83 pub seo: SeoInfo,
84}
85
86#[derive(Debug, Clone, Default, Serialize, Deserialize)]
88pub struct SeoInfo {
89 #[serde(default)]
90 pub profile_url: String,
91 #[serde(default)]
92 pub dofollow: bool,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct SeoStats {
98 pub agent_id: String,
99 pub seo_grade: String,
100 pub seo_score: u32,
101 pub profiles: ProfileUrls,
102 pub schema_org: bool,
103 pub speakable_markup: bool,
104 pub og_tags: bool,
105 #[serde(default)]
106 pub has_custom_seo_url: bool,
107 #[serde(default)]
108 pub enhancement_summary: String,
109 #[serde(default)]
110 pub recommendation: Option<String>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct ProfileUrls {
116 pub html: String,
117 pub json: String,
118 pub xml: String,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct SeoReport {
124 pub total_agents: u32,
125 pub native_agents: u32,
126 pub relay_agents: u32,
127 pub agents_with_custom_seo: u32,
128 pub version: String,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct DiscoveredAgent {
134 pub agent_id: String,
135 #[serde(default)]
136 pub name: String,
137 #[serde(default)]
138 pub provider: String,
139 #[serde(default)]
140 pub status: String,
141 #[serde(default)]
142 pub beat_count: u64,
143 #[serde(default)]
144 pub capabilities: Vec<String>,
145 #[serde(default)]
146 pub profile_url: String,
147 #[serde(default)]
148 pub seo_url: String,
149}
150
151pub struct RelayClient {
157 base_url: String,
158 http: Client,
159}
160
161impl RelayClient {
162 pub fn new(base_url: &str) -> Self {
166 Self {
167 base_url: base_url.trim_end_matches('/').to_string(),
168 http: Client::builder()
169 .danger_accept_invalid_certs(true)
170 .build()
171 .unwrap_or_default(),
172 }
173 }
174
175 pub fn register(
179 &self,
180 name: &str,
181 provider: &str,
182 capabilities: &[&str],
183 ) -> Result<Registration> {
184 let mut hasher = Sha256::new();
186 hasher.update(format!("beacon-rust-agent-{name}"));
187 let seed = hex::encode(hasher.finalize());
188 let pubkey_hex = &seed[..64];
189
190 let payload = serde_json::json!({
191 "pubkey_hex": pubkey_hex,
192 "model_id": "rust-native",
193 "provider": provider,
194 "capabilities": capabilities,
195 "name": name,
196 });
197
198 let resp: serde_json::Value = self
199 .http
200 .post(format!("{}/beacon/relay/register", self.base_url))
201 .json(&payload)
202 .send()?
203 .json()?;
204
205 if resp.get("ok").and_then(|v| v.as_bool()) == Some(true) {
206 Ok(serde_json::from_value(resp)?)
207 } else {
208 Err(BeaconError::Relay(
209 resp.get("error")
210 .and_then(|v| v.as_str())
211 .unwrap_or("unknown error")
212 .to_string(),
213 ))
214 }
215 }
216
217 pub fn heartbeat(&self, agent_id: &str, token: &str) -> Result<Heartbeat> {
219 let payload = serde_json::json!({
220 "agent_id": agent_id,
221 "status": "alive",
222 });
223
224 let resp: serde_json::Value = self
225 .http
226 .post(format!("{}/beacon/relay/heartbeat", self.base_url))
227 .bearer_auth(token)
228 .json(&payload)
229 .send()?
230 .json()?;
231
232 if resp.get("ok").and_then(|v| v.as_bool()) == Some(true) {
233 Ok(serde_json::from_value(resp)?)
234 } else {
235 Err(BeaconError::Relay(
236 resp.get("error")
237 .and_then(|v| v.as_str())
238 .unwrap_or("unknown")
239 .to_string(),
240 ))
241 }
242 }
243
244 pub fn heartbeat_seo(
246 &self,
247 agent_id: &str,
248 token: &str,
249 seo_url: Option<&str>,
250 seo_description: Option<&str>,
251 ) -> Result<Heartbeat> {
252 let mut payload = serde_json::json!({
253 "agent_id": agent_id,
254 "status": "alive",
255 });
256
257 if let Some(url) = seo_url {
258 payload["seo_url"] = serde_json::Value::String(url.to_string());
259 }
260 if let Some(desc) = seo_description {
261 payload["seo_description"] = serde_json::Value::String(desc.to_string());
262 }
263
264 let resp: serde_json::Value = self
265 .http
266 .post(format!("{}/beacon/relay/heartbeat/seo", self.base_url))
267 .bearer_auth(token)
268 .json(&payload)
269 .send()?
270 .json()?;
271
272 if resp.get("ok").and_then(|v| v.as_bool()) == Some(true) {
273 Ok(serde_json::from_value(resp)?)
274 } else {
275 Err(BeaconError::Relay(
276 resp.get("error")
277 .and_then(|v| v.as_str())
278 .unwrap_or("unknown")
279 .to_string(),
280 ))
281 }
282 }
283
284 pub fn seo_stats(&self, agent_id: &str) -> Result<SeoStats> {
286 let resp: SeoStats = self
287 .http
288 .get(format!(
289 "{}/beacon/relay/seo/stats/{agent_id}",
290 self.base_url
291 ))
292 .send()?
293 .json()?;
294
295 Ok(resp)
296 }
297
298 pub fn seo_report(&self) -> Result<SeoReport> {
300 let resp: SeoReport = self
301 .http
302 .get(format!("{}/beacon/relay/seo/report", self.base_url))
303 .send()?
304 .json()?;
305
306 Ok(resp)
307 }
308
309 pub fn discover(&self, include_dead: bool) -> Result<Vec<DiscoveredAgent>> {
311 let url = if include_dead {
312 format!(
313 "{}/beacon/relay/discover?include_dead=true",
314 self.base_url
315 )
316 } else {
317 format!("{}/beacon/relay/discover", self.base_url)
318 };
319
320 let resp: Vec<DiscoveredAgent> = self.http.get(&url).send()?.json()?;
321 Ok(resp)
322 }
323
324 pub fn agent_profile_json(&self, agent_id: &str) -> Result<serde_json::Value> {
326 let resp: serde_json::Value = self
327 .http
328 .get(format!(
329 "{}/beacon/agent/{agent_id}.json",
330 self.base_url
331 ))
332 .send()?
333 .json()?;
334
335 Ok(resp)
336 }
337
338 pub fn agent_profile_xml(&self, agent_id: &str) -> Result<String> {
340 let resp = self
341 .http
342 .get(format!(
343 "{}/beacon/agent/{agent_id}.xml",
344 self.base_url
345 ))
346 .send()?
347 .text()?;
348
349 Ok(resp)
350 }
351
352 pub fn llms_txt(&self) -> Result<String> {
354 let resp = self
355 .http
356 .get(format!("{}/beacon/llms.txt", self.base_url))
357 .send()?
358 .text()?;
359
360 Ok(resp)
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
369 fn test_client_creation() {
370 let client = RelayClient::new("https://rustchain.org");
371 assert_eq!(client.base_url, "https://rustchain.org");
372 }
373
374 #[test]
375 fn test_client_strips_trailing_slash() {
376 let client = RelayClient::new("https://rustchain.org/");
377 assert_eq!(client.base_url, "https://rustchain.org");
378 }
379}