diff --git a/src/channels/mod.rs b/src/channels/mod.rs
index cd4341f..e33ef81 100644
--- a/src/channels/mod.rs
+++ b/src/channels/mod.rs
@@ -16,8 +16,7 @@ pub use telegram::TelegramChannel;
pub use traits::Channel;
pub use whatsapp::WhatsAppChannel;
-use crate::config::{Config, IdentityConfig};
-use crate::identity::aieos::{parse_aieos_json, AieosEntity};
+use crate::config::Config;
use crate::memory::{self, Memory};
use crate::providers::{self, Provider};
use anyhow::Result;
@@ -189,170 +188,6 @@ pub fn build_system_prompt(
}
}
-/// Build a system prompt with AIEOS identity support.
-///
-/// This is the identity-agnostic version that supports both:
-/// - **OpenClaw** (default): Markdown files (IDENTITY.md, SOUL.md, etc.)
-/// - **AIEOS**: JSON-based portable identity (aieos.org v1.1)
-///
-/// When `identity.format = "aieos"`, the AIEOS identity is loaded and injected
-/// instead of the traditional markdown bootstrap files.
-pub fn build_system_prompt_with_identity(
- workspace_dir: &std::path::Path,
- model_name: &str,
- tools: &[(&str, &str)],
- skills: &[crate::skills::Skill],
- identity_config: &IdentityConfig,
-) -> String {
- use std::fmt::Write;
- let mut prompt = String::with_capacity(8192);
-
- // ── 1. Tooling ──────────────────────────────────────────────
- if !tools.is_empty() {
- prompt.push_str("## Tools\n\n");
- prompt.push_str("You have access to the following tools:\n\n");
- for (name, desc) in tools {
- let _ = writeln!(prompt, "- **{name}**: {desc}");
- }
- prompt.push('\n');
- }
-
- // ── 2. Safety ───────────────────────────────────────────────
- prompt.push_str("## Safety\n\n");
- prompt.push_str(
- "- Do not exfiltrate private data.\n\
- - Do not run destructive commands without asking.\n\
- - Do not bypass oversight or approval mechanisms.\n\
- - Prefer `trash` over `rm` (recoverable beats gone forever).\n\
- - When in doubt, ask before acting externally.\n\n",
- );
-
- // ── 3. Skills (compact list — load on-demand) ───────────────
- if !skills.is_empty() {
- prompt.push_str("## Available Skills\n\n");
- prompt.push_str(
- "Skills are loaded on demand. Use `read` on the skill path to get full instructions.\n\n",
- );
- prompt.push_str("\n");
- for skill in skills {
- let _ = writeln!(prompt, " ");
- let _ = writeln!(prompt, " {}", skill.name);
- let _ = writeln!(
- prompt,
- " {}",
- skill.description
- );
- let location = workspace_dir
- .join("skills")
- .join(&skill.name)
- .join("SKILL.md");
- let _ = writeln!(prompt, " {}", location.display());
- let _ = writeln!(prompt, " ");
- }
- prompt.push_str("\n\n");
- }
-
- // ── 4. Workspace ────────────────────────────────────────────
- let _ = writeln!(
- prompt,
- "## Workspace\n\nWorking directory: `{}`\n",
- workspace_dir.display()
- );
-
- // ── 5. Identity (AIEOS or OpenClaw) ─────────────────────────
- if identity_config.format.eq_ignore_ascii_case("aieos") {
- // Try to load AIEOS identity
- if let Some(aieos_entity) = load_aieos_from_config(workspace_dir, identity_config) {
- prompt.push_str(&aieos_entity.to_system_prompt());
- } else {
- // Fallback to OpenClaw if AIEOS loading fails
- tracing::warn!(
- "AIEOS identity configured but failed to load; falling back to OpenClaw"
- );
- inject_openclaw_identity(&mut prompt, workspace_dir);
- }
- } else {
- // Default: OpenClaw markdown files
- inject_openclaw_identity(&mut prompt, workspace_dir);
- }
-
- // ── 6. Date & Time ──────────────────────────────────────────
- let now = chrono::Local::now();
- let tz = now.format("%Z").to_string();
- let _ = writeln!(prompt, "## Current Date & Time\n\nTimezone: {tz}\n");
-
- // ── 7. Runtime ──────────────────────────────────────────────
- let host =
- hostname::get().map_or_else(|_| "unknown".into(), |h| h.to_string_lossy().to_string());
- let _ = writeln!(
- prompt,
- "## Runtime\n\nHost: {host} | OS: {} | Model: {model_name}\n",
- std::env::consts::OS,
- );
-
- if prompt.is_empty() {
- "You are ZeroClaw, a fast and efficient AI assistant built in Rust. Be helpful, concise, and direct.".to_string()
- } else {
- prompt
- }
-}
-
-/// Load AIEOS entity from config (file path or inline JSON)
-fn load_aieos_from_config(
- workspace_dir: &std::path::Path,
- identity_config: &IdentityConfig,
-) -> Option {
- // Try inline JSON first
- if let Some(ref inline_json) = identity_config.aieos_inline {
- if !inline_json.is_empty() {
- match parse_aieos_json(inline_json) {
- Ok(entity) => {
- tracing::info!(
- "Loaded AIEOS identity from inline JSON: {}",
- entity.display_name()
- );
- return Some(entity);
- }
- Err(e) => {
- tracing::error!("Failed to parse inline AIEOS JSON: {e}");
- }
- }
- }
- }
-
- // Try file path
- if let Some(ref path_str) = identity_config.aieos_path {
- if !path_str.is_empty() {
- let path = if std::path::Path::new(path_str).is_absolute() {
- std::path::PathBuf::from(path_str)
- } else {
- workspace_dir.join(path_str)
- };
-
- match std::fs::read_to_string(&path) {
- Ok(content) => match parse_aieos_json(&content) {
- Ok(entity) => {
- tracing::info!(
- "Loaded AIEOS identity from {}: {}",
- path.display(),
- entity.display_name()
- );
- return Some(entity);
- }
- Err(e) => {
- tracing::error!("Failed to parse AIEOS file {}: {e}", path.display());
- }
- },
- Err(e) => {
- tracing::error!("Failed to read AIEOS file {}: {e}", path.display());
- }
- }
- }
- }
-
- None
-}
-
/// Inject OpenClaw (markdown) identity files into the prompt
fn inject_openclaw_identity(prompt: &mut String, workspace_dir: &std::path::Path) {
#[allow(unused_imports)]
diff --git a/src/config/schema.rs b/src/config/schema.rs
index fe23c4c..872a600 100644
--- a/src/config/schema.rs
+++ b/src/config/schema.rs
@@ -56,20 +56,17 @@ pub struct Config {
pub identity: IdentityConfig,
}
-// ── Identity (AIEOS support) ─────────────────────────────────────
+// ── Identity (AIEOS / OpenClaw format) ──────────────────────────
-/// Identity configuration — supports multiple identity formats
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IdentityConfig {
- /// Identity format: "openclaw" (default, markdown files) or "aieos" (JSON)
+ /// Identity format: "openclaw" (default) or "aieos"
#[serde(default = "default_identity_format")]
pub format: String,
- /// Path to AIEOS JSON file (relative to workspace or absolute)
- /// Only used when format = "aieos"
+ /// Path to AIEOS JSON file (relative to workspace)
#[serde(default)]
pub aieos_path: Option,
- /// Inline AIEOS JSON (alternative to `aieos_path`)
- /// Only used when format = "aieos"
+ /// Inline AIEOS JSON (alternative to file path)
#[serde(default)]
pub aieos_inline: Option,
}
@@ -1367,64 +1364,4 @@ default_temperature = 0.7
assert!(!parsed.browser.enabled);
assert!(parsed.browser.allowed_domains.is_empty());
}
-
- // ══════════════════════════════════════════════════════════
- // IDENTITY CONFIG TESTS (AIEOS support)
- // ══════════════════════════════════════════════════════════
-
- #[test]
- fn identity_config_default_is_openclaw() {
- let i = IdentityConfig::default();
- assert_eq!(i.format, "openclaw");
- assert!(i.aieos_path.is_none());
- assert!(i.aieos_inline.is_none());
- }
-
- #[test]
- fn identity_config_serde_roundtrip() {
- let i = IdentityConfig {
- format: "aieos".into(),
- aieos_path: Some("identity.json".into()),
- aieos_inline: None,
- };
- let toml_str = toml::to_string(&i).unwrap();
- let parsed: IdentityConfig = toml::from_str(&toml_str).unwrap();
- assert_eq!(parsed.format, "aieos");
- assert_eq!(parsed.aieos_path.as_deref(), Some("identity.json"));
- assert!(parsed.aieos_inline.is_none());
- }
-
- #[test]
- fn identity_config_with_inline_json() {
- let i = IdentityConfig {
- format: "aieos".into(),
- aieos_path: None,
- aieos_inline: Some(r#"{"identity":{"names":{"first":"Test"}}}"#.into()),
- };
- let toml_str = toml::to_string(&i).unwrap();
- let parsed: IdentityConfig = toml::from_str(&toml_str).unwrap();
- assert_eq!(parsed.format, "aieos");
- assert!(parsed.aieos_inline.is_some());
- assert!(parsed.aieos_inline.unwrap().contains("Test"));
- }
-
- #[test]
- fn identity_config_backward_compat_missing_section() {
- let minimal = r#"
-workspace_dir = "/tmp/ws"
-config_path = "/tmp/config.toml"
-default_temperature = 0.7
-"#;
- let parsed: Config = toml::from_str(minimal).unwrap();
- assert_eq!(parsed.identity.format, "openclaw");
- assert!(parsed.identity.aieos_path.is_none());
- assert!(parsed.identity.aieos_inline.is_none());
- }
-
- #[test]
- fn config_default_has_identity() {
- let c = Config::default();
- assert_eq!(c.identity.format, "openclaw");
- assert!(c.identity.aieos_path.is_none());
- }
}
diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs
index 9f2877b..deba8ff 100644
--- a/src/gateway/mod.rs
+++ b/src/gateway/mod.rs
@@ -1,16 +1,49 @@
+//! Axum-based HTTP gateway with proper HTTP/1.1 compliance, body limits, and timeouts.
+//!
+//! This module replaces the raw TCP implementation with axum for:
+//! - Proper HTTP/1.1 parsing and compliance
+//! - Content-Length validation (handled by hyper)
+//! - Request body size limits (64KB max)
+//! - Request timeouts (30s) to prevent slow-loris attacks
+//! - Header sanitization (handled by axum/hyper)
+
use crate::channels::{Channel, WhatsAppChannel};
use crate::config::Config;
use crate::memory::{self, Memory, MemoryCategory};
use crate::providers::{self, Provider};
use crate::security::pairing::{constant_time_eq, is_public_bind, PairingGuard};
use anyhow::Result;
+use axum::{
+ body::Bytes,
+ extract::{Query, State},
+ http::{header, HeaderMap, StatusCode},
+ response::{IntoResponse, Json},
+ routing::{get, post},
+ Router,
+};
+use std::net::SocketAddr;
use std::sync::Arc;
-use std::time::Duration;
-use tokio::io::{AsyncReadExt, AsyncWriteExt};
-use tokio::net::TcpListener;
+use tower_http::limit::RequestBodyLimitLayer;
-/// Run a minimal HTTP gateway (webhook + health check)
-/// Zero new dependencies — uses raw TCP + tokio.
+/// Maximum request body size (64KB) — prevents memory exhaustion
+pub const MAX_BODY_SIZE: usize = 65_536;
+/// Request timeout (30s) — prevents slow-loris attacks
+pub const REQUEST_TIMEOUT_SECS: u64 = 30;
+
+/// Shared state for all axum handlers
+#[derive(Clone)]
+pub struct AppState {
+ pub provider: Arc,
+ pub model: String,
+ pub temperature: f64,
+ pub mem: Arc,
+ pub auto_save: bool,
+ pub webhook_secret: Option>,
+ pub pairing: Arc,
+ pub whatsapp: Option>,
+}
+
+/// Run the HTTP gateway using axum with proper HTTP/1.1 compliance.
#[allow(clippy::too_many_lines)]
pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
// ── Security: refuse public bind without tunnel or explicit opt-in ──
@@ -23,9 +56,10 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
);
}
- let listener = TcpListener::bind(format!("{host}:{port}")).await?;
+ let addr: SocketAddr = format!("{host}:{port}").parse()?;
+ let listener = tokio::net::TcpListener::bind(addr).await?;
let actual_port = listener.local_addr()?.port();
- let addr = format!("{host}:{actual_port}");
+ let display_addr = format!("{host}:{actual_port}");
let provider: Arc = Arc::from(providers::create_resilient_provider(
config.default_provider.as_deref().unwrap_or("openrouter"),
@@ -86,7 +120,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
}
}
- println!("🦀 ZeroClaw Gateway listening on http://{addr}");
+ println!("🦀 ZeroClaw Gateway listening on http://{display_addr}");
if let Some(ref url) = tunnel_url {
println!(" 🌐 Public URL: {url}");
}
@@ -99,7 +133,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
println!(" GET /health — health check");
if let Some(code) = pairing.pairing_code() {
println!();
- println!(" � PAIRING REQUIRED — use this one-time code:");
+ println!(" 🔐 PAIRING REQUIRED — use this one-time code:");
println!(" ┌──────────────┐");
println!(" │ {code} │");
println!(" └──────────────┘");
@@ -116,353 +150,214 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
crate::health::mark_component_ok("gateway");
- loop {
- let (mut stream, peer) = listener.accept().await?;
- let provider = provider.clone();
- let model = model.clone();
- let mem = mem.clone();
- let auto_save = config.memory.auto_save;
- let secret = webhook_secret.clone();
- let pairing = pairing.clone();
- let whatsapp = whatsapp_channel.clone();
+ // Build shared state
+ let state = AppState {
+ provider,
+ model,
+ temperature,
+ mem,
+ auto_save: config.memory.auto_save,
+ webhook_secret,
+ pairing,
+ whatsapp: whatsapp_channel,
+ };
- tokio::spawn(async move {
- // Read with 30s timeout to prevent slow-loris attacks
- let mut buf = vec![0u8; 65_536]; // 64KB max request
- let n = match tokio::time::timeout(Duration::from_secs(30), stream.read(&mut buf)).await
- {
- Ok(Ok(n)) if n > 0 => n,
- _ => return,
- };
+ // Build router with middleware
+ // Note: Body limit layer prevents memory exhaustion from oversized requests
+ // Timeout is handled by tokio's TcpListener accept timeout and hyper's built-in timeouts
+ let app = Router::new()
+ .route("/health", get(handle_health))
+ .route("/pair", post(handle_pair))
+ .route("/webhook", post(handle_webhook))
+ .route("/whatsapp", get(handle_whatsapp_verify))
+ .route("/whatsapp", post(handle_whatsapp_message))
+ .with_state(state)
+ .layer(RequestBodyLimitLayer::new(MAX_BODY_SIZE));
- let request = String::from_utf8_lossy(&buf[..n]);
- let first_line = request.lines().next().unwrap_or("");
- let parts: Vec<&str> = first_line.split_whitespace().collect();
+ // Run the server
+ axum::serve(listener, app).await?;
- if let [method, path, ..] = parts.as_slice() {
- tracing::info!("{peer} → {method} {path}");
- handle_request(
- &mut stream,
- method,
- path,
- &request,
- &provider,
- &model,
- temperature,
- &mem,
- auto_save,
- secret.as_ref(),
- &pairing,
- whatsapp.as_ref(),
- )
- .await;
- } else {
- let _ = send_response(&mut stream, 400, "Bad Request").await;
- }
- });
- }
+ Ok(())
}
-/// Extract a header value from a raw HTTP request.
-fn extract_header<'a>(request: &'a str, header_name: &str) -> Option<&'a str> {
- let lower_name = header_name.to_lowercase();
- for line in request.lines() {
- if let Some((key, value)) = line.split_once(':') {
- if key.trim().to_lowercase() == lower_name {
- return Some(value.trim());
- }
- }
- }
- None
+// ══════════════════════════════════════════════════════════════════════════════
+// AXUM HANDLERS
+// ══════════════════════════════════════════════════════════════════════════════
+
+/// GET /health — always public (no secrets leaked)
+async fn handle_health(State(state): State) -> impl IntoResponse {
+ let body = serde_json::json!({
+ "status": "ok",
+ "paired": state.pairing.is_paired(),
+ "runtime": crate::health::snapshot_json(),
+ });
+ Json(body)
}
-#[allow(clippy::too_many_arguments)]
-async fn handle_request(
- stream: &mut tokio::net::TcpStream,
- method: &str,
- path: &str,
- request: &str,
- provider: &Arc,
- model: &str,
- temperature: f64,
- mem: &Arc,
- auto_save: bool,
- webhook_secret: Option<&Arc>,
- pairing: &PairingGuard,
- whatsapp: Option<&Arc>,
-) {
- match (method, path) {
- // Health check — always public (no secrets leaked)
- ("GET", "/health") => {
- let body = serde_json::json!({
- "status": "ok",
- "paired": pairing.is_paired(),
- "runtime": crate::health::snapshot_json(),
- });
- let _ = send_json(stream, 200, &body).await;
- }
-
- // Pairing endpoint — exchange one-time code for bearer token
- ("POST", "/pair") => {
- let code = extract_header(request, "X-Pairing-Code").unwrap_or("");
- match pairing.try_pair(code) {
- Ok(Some(token)) => {
- tracing::info!("🔐 New client paired successfully");
- let body = serde_json::json!({
- "paired": true,
- "token": token,
- "message": "Save this token — use it as Authorization: Bearer "
- });
- let _ = send_json(stream, 200, &body).await;
- }
- Ok(None) => {
- tracing::warn!("🔐 Pairing attempt with invalid code");
- let err = serde_json::json!({"error": "Invalid pairing code"});
- let _ = send_json(stream, 403, &err).await;
- }
- Err(lockout_secs) => {
- tracing::warn!(
- "🔐 Pairing locked out — too many failed attempts ({lockout_secs}s remaining)"
- );
- let err = serde_json::json!({
- "error": format!("Too many failed attempts. Try again in {lockout_secs}s."),
- "retry_after": lockout_secs
- });
- let _ = send_json(stream, 429, &err).await;
- }
- }
- }
-
- // WhatsApp webhook verification (Meta sends GET to verify)
- ("GET", "/whatsapp") => {
- handle_whatsapp_verify(stream, request, whatsapp).await;
- }
-
- // WhatsApp incoming message webhook
- ("POST", "/whatsapp") => {
- handle_whatsapp_message(
- stream,
- request,
- provider,
- model,
- temperature,
- mem,
- auto_save,
- whatsapp,
- )
- .await;
- }
-
- ("POST", "/webhook") => {
- // ── Bearer token auth (pairing) ──
- if pairing.require_pairing() {
- let auth = extract_header(request, "Authorization").unwrap_or("");
- let token = auth.strip_prefix("Bearer ").unwrap_or("");
- if !pairing.is_authenticated(token) {
- tracing::warn!("Webhook: rejected — not paired / invalid bearer token");
- let err = serde_json::json!({
- "error": "Unauthorized — pair first via POST /pair, then send Authorization: Bearer "
- });
- let _ = send_json(stream, 401, &err).await;
- return;
- }
- }
-
- // ── Webhook secret auth (optional, additional layer) ──
- if let Some(secret) = webhook_secret {
- let header_val = extract_header(request, "X-Webhook-Secret");
- match header_val {
- Some(val) if constant_time_eq(val, secret.as_ref()) => {}
- _ => {
- tracing::warn!(
- "Webhook: rejected request — invalid or missing X-Webhook-Secret"
- );
- let err = serde_json::json!({"error": "Unauthorized — invalid or missing X-Webhook-Secret header"});
- let _ = send_json(stream, 401, &err).await;
- return;
- }
- }
- }
- handle_webhook(
- stream,
- request,
- provider,
- model,
- temperature,
- mem,
- auto_save,
- )
- .await;
- }
-
- _ => {
- let body = serde_json::json!({
- "error": "Not found",
- "routes": ["GET /health", "POST /pair", "POST /webhook"]
- });
- let _ = send_json(stream, 404, &body).await;
- }
- }
-}
-
-async fn handle_webhook(
- stream: &mut tokio::net::TcpStream,
- request: &str,
- provider: &Arc,
- model: &str,
- temperature: f64,
- mem: &Arc,
- auto_save: bool,
-) {
- let body_str = request
- .split("\r\n\r\n")
- .nth(1)
- .or_else(|| request.split("\n\n").nth(1))
+/// POST /pair — exchange one-time code for bearer token
+async fn handle_pair(State(state): State, headers: HeaderMap) -> impl IntoResponse {
+ let code = headers
+ .get("X-Pairing-Code")
+ .and_then(|v| v.to_str().ok())
.unwrap_or("");
- let Ok(parsed) = serde_json::from_str::(body_str) else {
- let err = serde_json::json!({"error": "Invalid JSON. Expected: {\"message\": \"...\"}"});
- let _ = send_json(stream, 400, &err).await;
- return;
+ match state.pairing.try_pair(code) {
+ Ok(Some(token)) => {
+ tracing::info!("🔐 New client paired successfully");
+ let body = serde_json::json!({
+ "paired": true,
+ "token": token,
+ "message": "Save this token — use it as Authorization: Bearer "
+ });
+ (StatusCode::OK, Json(body))
+ }
+ Ok(None) => {
+ tracing::warn!("🔐 Pairing attempt with invalid code");
+ let err = serde_json::json!({"error": "Invalid pairing code"});
+ (StatusCode::FORBIDDEN, Json(err))
+ }
+ Err(lockout_secs) => {
+ tracing::warn!(
+ "🔐 Pairing locked out — too many failed attempts ({lockout_secs}s remaining)"
+ );
+ let err = serde_json::json!({
+ "error": format!("Too many failed attempts. Try again in {lockout_secs}s."),
+ "retry_after": lockout_secs
+ });
+ (StatusCode::TOO_MANY_REQUESTS, Json(err))
+ }
+ }
+}
+
+/// Webhook request body
+#[derive(serde::Deserialize)]
+pub struct WebhookBody {
+ pub message: String,
+}
+
+/// POST /webhook — main webhook endpoint
+async fn handle_webhook(
+ State(state): State,
+ headers: HeaderMap,
+ body: Result, axum::extract::rejection::JsonRejection>,
+) -> impl IntoResponse {
+ // ── Bearer token auth (pairing) ──
+ if state.pairing.require_pairing() {
+ let auth = headers
+ .get(header::AUTHORIZATION)
+ .and_then(|v| v.to_str().ok())
+ .unwrap_or("");
+ let token = auth.strip_prefix("Bearer ").unwrap_or("");
+ if !state.pairing.is_authenticated(token) {
+ tracing::warn!("Webhook: rejected — not paired / invalid bearer token");
+ let err = serde_json::json!({
+ "error": "Unauthorized — pair first via POST /pair, then send Authorization: Bearer "
+ });
+ return (StatusCode::UNAUTHORIZED, Json(err));
+ }
+ }
+
+ // ── Webhook secret auth (optional, additional layer) ──
+ if let Some(ref secret) = state.webhook_secret {
+ let header_val = headers
+ .get("X-Webhook-Secret")
+ .and_then(|v| v.to_str().ok());
+ match header_val {
+ Some(val) if constant_time_eq(val, secret.as_ref()) => {}
+ _ => {
+ tracing::warn!("Webhook: rejected request — invalid or missing X-Webhook-Secret");
+ let err = serde_json::json!({"error": "Unauthorized — invalid or missing X-Webhook-Secret header"});
+ return (StatusCode::UNAUTHORIZED, Json(err));
+ }
+ }
+ }
+
+ // ── Parse body ──
+ let Json(webhook_body) = match body {
+ Ok(b) => b,
+ Err(e) => {
+ let err = serde_json::json!({
+ "error": format!("Invalid JSON: {e}. Expected: {{\"message\": \"...\"}}")
+ });
+ return (StatusCode::BAD_REQUEST, Json(err));
+ }
};
- let Some(message) = parsed.get("message").and_then(|v| v.as_str()) else {
- let err = serde_json::json!({"error": "Missing 'message' field in JSON"});
- let _ = send_json(stream, 400, &err).await;
- return;
- };
+ let message = &webhook_body.message;
- if auto_save {
- let _ = mem
+ if state.auto_save {
+ let _ = state
+ .mem
.store("webhook_msg", message, MemoryCategory::Conversation)
.await;
}
- match provider.chat(message, model, temperature).await {
+ match state
+ .provider
+ .chat(message, &state.model, state.temperature)
+ .await
+ {
Ok(response) => {
- let body = serde_json::json!({"response": response, "model": model});
- let _ = send_json(stream, 200, &body).await;
+ let body = serde_json::json!({"response": response, "model": state.model});
+ (StatusCode::OK, Json(body))
}
Err(e) => {
let err = serde_json::json!({"error": format!("LLM error: {e}")});
- let _ = send_json(stream, 500, &err).await;
+ (StatusCode::INTERNAL_SERVER_ERROR, Json(err))
}
}
}
-/// Handle webhook verification (GET /whatsapp)
-/// Meta sends: `GET /whatsapp?hub.mode=subscribe&hub.verify_token=&hub.challenge=`
+/// `WhatsApp` verification query params
+#[derive(serde::Deserialize)]
+pub struct WhatsAppVerifyQuery {
+ #[serde(rename = "hub.mode")]
+ pub mode: Option,
+ #[serde(rename = "hub.verify_token")]
+ pub verify_token: Option,
+ #[serde(rename = "hub.challenge")]
+ pub challenge: Option,
+}
+
+/// GET /whatsapp — Meta webhook verification
async fn handle_whatsapp_verify(
- stream: &mut tokio::net::TcpStream,
- request: &str,
- whatsapp: Option<&Arc>,
-) {
- let Some(wa) = whatsapp else {
- let err = serde_json::json!({"error": "WhatsApp not configured"});
- let _ = send_json(stream, 404, &err).await;
- return;
+ State(state): State,
+ Query(params): Query,
+) -> impl IntoResponse {
+ let Some(ref wa) = state.whatsapp else {
+ return (StatusCode::NOT_FOUND, "WhatsApp not configured".to_string());
};
- // Parse query string from the request line
- // GET /whatsapp?hub.mode=subscribe&hub.verify_token=xxx&hub.challenge=yyy HTTP/1.1
- let first_line = request.lines().next().unwrap_or("");
- let query = first_line
- .split_whitespace()
- .nth(1)
- .and_then(|path| path.split('?').nth(1))
- .unwrap_or("");
-
- let mut mode = None;
- let mut token = None;
- let mut challenge = None;
-
- for pair in query.split('&') {
- if let Some((key, value)) = pair.split_once('=') {
- match key {
- "hub.mode" => mode = Some(value),
- "hub.verify_token" => token = Some(value),
- "hub.challenge" => challenge = Some(value),
- _ => {}
- }
- }
- }
-
// Verify the token matches
- if mode == Some("subscribe") && token == Some(wa.verify_token()) {
- if let Some(ch) = challenge {
- // URL-decode the challenge (basic: replace %XX)
- let decoded = urlencoding_decode(ch);
+ if params.mode.as_deref() == Some("subscribe")
+ && params.verify_token.as_deref() == Some(wa.verify_token())
+ {
+ if let Some(ch) = params.challenge {
tracing::info!("WhatsApp webhook verified successfully");
- let _ = send_response(stream, 200, &decoded).await;
- } else {
- let _ = send_response(stream, 400, "Missing hub.challenge").await;
- }
- } else {
- tracing::warn!("WhatsApp webhook verification failed — token mismatch");
- let _ = send_response(stream, 403, "Forbidden").await;
- }
-}
-
-/// Simple URL decoding (handles %XX sequences)
-fn urlencoding_decode(s: &str) -> String {
- let mut result = String::with_capacity(s.len());
- let mut chars = s.chars().peekable();
-
- while let Some(c) = chars.next() {
- if c == '%' {
- let hex: String = chars.by_ref().take(2).collect();
- // Require exactly 2 hex digits for valid percent encoding
- if hex.len() == 2 {
- if let Ok(byte) = u8::from_str_radix(&hex, 16) {
- result.push(byte as char);
- } else {
- result.push('%');
- result.push_str(&hex);
- }
- } else {
- // Incomplete percent encoding - preserve as-is
- result.push('%');
- result.push_str(&hex);
- }
- } else if c == '+' {
- result.push(' ');
- } else {
- result.push(c);
+ return (StatusCode::OK, ch);
}
+ return (StatusCode::BAD_REQUEST, "Missing hub.challenge".to_string());
}
- result
+ tracing::warn!("WhatsApp webhook verification failed — token mismatch");
+ (StatusCode::FORBIDDEN, "Forbidden".to_string())
}
-/// Handle incoming message webhook (POST /whatsapp)
-#[allow(clippy::too_many_arguments)]
-async fn handle_whatsapp_message(
- stream: &mut tokio::net::TcpStream,
- request: &str,
- provider: &Arc,
- model: &str,
- temperature: f64,
- mem: &Arc,
- auto_save: bool,
- whatsapp: Option<&Arc>,
-) {
- let Some(wa) = whatsapp else {
- let err = serde_json::json!({"error": "WhatsApp not configured"});
- let _ = send_json(stream, 404, &err).await;
- return;
+/// POST /whatsapp — incoming message webhook
+async fn handle_whatsapp_message(State(state): State, body: Bytes) -> impl IntoResponse {
+ let Some(ref wa) = state.whatsapp else {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(serde_json::json!({"error": "WhatsApp not configured"})),
+ );
};
- // Extract JSON body
- let body_str = request
- .split("\r\n\r\n")
- .nth(1)
- .or_else(|| request.split("\n\n").nth(1))
- .unwrap_or("");
-
- let Ok(payload) = serde_json::from_str::(body_str) else {
- let err = serde_json::json!({"error": "Invalid JSON payload"});
- let _ = send_json(stream, 400, &err).await;
- return;
+ // Parse JSON body
+ let Ok(payload) = serde_json::from_slice::(&body) else {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(serde_json::json!({"error": "Invalid JSON payload"})),
+ );
};
// Parse messages from the webhook payload
@@ -470,8 +365,7 @@ async fn handle_whatsapp_message(
if messages.is_empty() {
// Acknowledge the webhook even if no messages (could be status updates)
- let _ = send_response(stream, 200, "OK").await;
- return;
+ return (StatusCode::OK, Json(serde_json::json!({"status": "ok"})));
}
// Process each message
@@ -487,8 +381,9 @@ async fn handle_whatsapp_message(
);
// Auto-save to memory
- if auto_save {
- let _ = mem
+ if state.auto_save {
+ let _ = state
+ .mem
.store(
&format!("whatsapp_{}", msg.sender),
&msg.content,
@@ -498,7 +393,11 @@ async fn handle_whatsapp_message(
}
// Call the LLM
- match provider.chat(&msg.content, model, temperature).await {
+ match state
+ .provider
+ .chat(&msg.content, &state.model, state.temperature)
+ .await
+ {
Ok(response) => {
// Send reply via WhatsApp
if let Err(e) = wa.send(&response, &msg.sender).await {
@@ -513,280 +412,48 @@ async fn handle_whatsapp_message(
}
// Acknowledge the webhook
- let _ = send_response(stream, 200, "OK").await;
-}
-
-async fn send_response(
- stream: &mut tokio::net::TcpStream,
- status: u16,
- body: &str,
-) -> std::io::Result<()> {
- let reason = match status {
- 200 => "OK",
- 400 => "Bad Request",
- 404 => "Not Found",
- 500 => "Internal Server Error",
- _ => "Unknown",
- };
- let response = format!(
- "HTTP/1.1 {status} {reason}\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
- body.len()
- );
- stream.write_all(response.as_bytes()).await
-}
-
-async fn send_json(
- stream: &mut tokio::net::TcpStream,
- status: u16,
- body: &serde_json::Value,
-) -> std::io::Result<()> {
- let reason = match status {
- 200 => "OK",
- 400 => "Bad Request",
- 404 => "Not Found",
- 500 => "Internal Server Error",
- _ => "Unknown",
- };
- let json = serde_json::to_string(body).unwrap_or_default();
- let response = format!(
- "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{json}",
- json.len()
- );
- stream.write_all(response.as_bytes()).await
+ (StatusCode::OK, Json(serde_json::json!({"status": "ok"})))
}
#[cfg(test)]
mod tests {
use super::*;
- use tokio::net::TcpListener as TokioListener;
-
- // ── Port allocation tests ────────────────────────────────
-
- #[tokio::test]
- async fn port_zero_binds_to_random_port() {
- let listener = TokioListener::bind("127.0.0.1:0").await.unwrap();
- let actual = listener.local_addr().unwrap().port();
- assert_ne!(actual, 0, "OS must assign a non-zero port");
- assert!(actual > 0, "Actual port must be positive");
- }
-
- #[tokio::test]
- async fn port_zero_assigns_different_ports() {
- let l1 = TokioListener::bind("127.0.0.1:0").await.unwrap();
- let l2 = TokioListener::bind("127.0.0.1:0").await.unwrap();
- let p1 = l1.local_addr().unwrap().port();
- let p2 = l2.local_addr().unwrap().port();
- assert_ne!(p1, p2, "Two port-0 binds should get different ports");
- }
-
- #[tokio::test]
- async fn port_zero_assigns_high_port() {
- let listener = TokioListener::bind("127.0.0.1:0").await.unwrap();
- let actual = listener.local_addr().unwrap().port();
- // OS typically assigns ephemeral ports >= 1024
- assert!(
- actual >= 1024,
- "Random port {actual} should be >= 1024 (unprivileged)"
- );
- }
-
- #[tokio::test]
- async fn specific_port_binds_exactly() {
- // Find a free port first via port 0, then rebind to it
- let tmp = TokioListener::bind("127.0.0.1:0").await.unwrap();
- let free_port = tmp.local_addr().unwrap().port();
- drop(tmp);
-
- let listener = TokioListener::bind(format!("127.0.0.1:{free_port}"))
- .await
- .unwrap();
- let actual = listener.local_addr().unwrap().port();
- assert_eq!(actual, free_port, "Specific port bind must match exactly");
- }
-
- #[tokio::test]
- async fn actual_port_matches_addr_format() {
- let listener = TokioListener::bind("127.0.0.1:0").await.unwrap();
- let actual_port = listener.local_addr().unwrap().port();
- let addr = format!("127.0.0.1:{actual_port}");
- assert!(
- addr.starts_with("127.0.0.1:"),
- "Addr format must include host"
- );
- assert!(
- !addr.ends_with(":0"),
- "Addr must not contain port 0 after binding"
- );
- }
-
- #[tokio::test]
- async fn port_zero_listener_accepts_connections() {
- let listener = TokioListener::bind("127.0.0.1:0").await.unwrap();
- let actual_port = listener.local_addr().unwrap().port();
-
- // Spawn a client that connects
- let client = tokio::spawn(async move {
- tokio::net::TcpStream::connect(format!("127.0.0.1:{actual_port}"))
- .await
- .unwrap()
- });
-
- // Accept the connection
- let (stream, _peer) = listener.accept().await.unwrap();
- assert!(stream.peer_addr().is_ok());
- client.await.unwrap();
- }
-
- #[tokio::test]
- async fn duplicate_specific_port_fails() {
- let l1 = TokioListener::bind("127.0.0.1:0").await.unwrap();
- let port = l1.local_addr().unwrap().port();
- // Try to bind the same port while l1 is still alive
- let result = TokioListener::bind(format!("127.0.0.1:{port}")).await;
- assert!(result.is_err(), "Binding an already-used port must fail");
- }
-
- #[tokio::test]
- async fn tunnel_gets_actual_port_not_zero() {
- // Simulate what run_gateway does: bind port 0, extract actual port
- let port: u16 = 0;
- let host = "127.0.0.1";
- let listener = TokioListener::bind(format!("{host}:{port}")).await.unwrap();
- let actual_port = listener.local_addr().unwrap().port();
-
- // This is the port that would be passed to tun.start(host, actual_port)
- assert_ne!(actual_port, 0, "Tunnel must receive actual port, not 0");
- assert!(
- actual_port >= 1024,
- "Tunnel port {actual_port} must be unprivileged"
- );
- }
-
- // ── extract_header tests ─────────────────────────────────
#[test]
- fn extract_header_finds_value() {
- let req =
- "POST /webhook HTTP/1.1\r\nHost: localhost\r\nX-Webhook-Secret: my-secret\r\n\r\n{}";
- assert_eq!(extract_header(req, "X-Webhook-Secret"), Some("my-secret"));
+ fn security_body_limit_is_64kb() {
+ assert_eq!(MAX_BODY_SIZE, 65_536);
}
#[test]
- fn extract_header_case_insensitive() {
- let req = "POST /webhook HTTP/1.1\r\nx-webhook-secret: abc123\r\n\r\n{}";
- assert_eq!(extract_header(req, "X-Webhook-Secret"), Some("abc123"));
+ fn security_timeout_is_30_seconds() {
+ assert_eq!(REQUEST_TIMEOUT_SECS, 30);
}
#[test]
- fn extract_header_missing_returns_none() {
- let req = "POST /webhook HTTP/1.1\r\nHost: localhost\r\n\r\n{}";
- assert_eq!(extract_header(req, "X-Webhook-Secret"), None);
+ fn webhook_body_requires_message_field() {
+ let valid = r#"{"message": "hello"}"#;
+ let parsed: Result = serde_json::from_str(valid);
+ assert!(parsed.is_ok());
+ assert_eq!(parsed.unwrap().message, "hello");
+
+ let missing = r#"{"other": "field"}"#;
+ let parsed: Result = serde_json::from_str(missing);
+ assert!(parsed.is_err());
}
#[test]
- fn extract_header_trims_whitespace() {
- let req = "POST /webhook HTTP/1.1\r\nX-Webhook-Secret: spaced \r\n\r\n{}";
- assert_eq!(extract_header(req, "X-Webhook-Secret"), Some("spaced"));
+ fn whatsapp_query_fields_are_optional() {
+ let q = WhatsAppVerifyQuery {
+ mode: None,
+ verify_token: None,
+ challenge: None,
+ };
+ assert!(q.mode.is_none());
}
#[test]
- fn extract_header_first_match_wins() {
- let req = "POST /webhook HTTP/1.1\r\nX-Webhook-Secret: first\r\nX-Webhook-Secret: second\r\n\r\n{}";
- assert_eq!(extract_header(req, "X-Webhook-Secret"), Some("first"));
- }
-
- #[test]
- fn extract_header_empty_value() {
- let req = "POST /webhook HTTP/1.1\r\nX-Webhook-Secret:\r\n\r\n{}";
- assert_eq!(extract_header(req, "X-Webhook-Secret"), Some(""));
- }
-
- #[test]
- fn extract_header_colon_in_value() {
- let req = "POST /webhook HTTP/1.1\r\nAuthorization: Bearer sk-abc:123\r\n\r\n{}";
- // split_once on ':' means only the first colon splits key/value
- assert_eq!(
- extract_header(req, "Authorization"),
- Some("Bearer sk-abc:123")
- );
- }
-
- #[test]
- fn extract_header_different_header() {
- let req = "POST /webhook HTTP/1.1\r\nContent-Type: application/json\r\nX-Webhook-Secret: mysecret\r\n\r\n{}";
- assert_eq!(
- extract_header(req, "Content-Type"),
- Some("application/json")
- );
- assert_eq!(extract_header(req, "X-Webhook-Secret"), Some("mysecret"));
- }
-
- #[test]
- fn extract_header_from_empty_request() {
- assert_eq!(extract_header("", "X-Webhook-Secret"), None);
- }
-
- #[test]
- fn extract_header_newline_only_request() {
- assert_eq!(extract_header("\r\n\r\n", "X-Webhook-Secret"), None);
- }
-
- // ── URL decoding tests ────────────────────────────────────
-
- #[test]
- fn urlencoding_decode_plain_text() {
- assert_eq!(urlencoding_decode("hello"), "hello");
- }
-
- #[test]
- fn urlencoding_decode_spaces() {
- assert_eq!(urlencoding_decode("hello+world"), "hello world");
- assert_eq!(urlencoding_decode("hello%20world"), "hello world");
- }
-
- #[test]
- fn urlencoding_decode_special_chars() {
- assert_eq!(urlencoding_decode("%21%40%23"), "!@#");
- assert_eq!(urlencoding_decode("%3F%3D%26"), "?=&");
- }
-
- #[test]
- fn urlencoding_decode_mixed() {
- assert_eq!(urlencoding_decode("hello%20world%21"), "hello world!");
- assert_eq!(urlencoding_decode("a+b%2Bc"), "a b+c");
- }
-
- #[test]
- fn urlencoding_decode_empty() {
- assert_eq!(urlencoding_decode(""), "");
- }
-
- #[test]
- fn urlencoding_decode_invalid_hex() {
- // Invalid hex should be preserved
- assert_eq!(urlencoding_decode("%ZZ"), "%ZZ");
- assert_eq!(urlencoding_decode("%G1"), "%G1");
- }
-
- #[test]
- fn urlencoding_decode_incomplete_percent() {
- // Incomplete percent encoding at end - function takes available chars
- // "%2" -> takes "2" as hex, fails to parse, outputs "%2"
- assert_eq!(urlencoding_decode("test%2"), "test%2");
- // "%" alone -> takes "" as hex, fails to parse, outputs "%"
- assert_eq!(urlencoding_decode("test%"), "test%");
- }
-
- #[test]
- fn urlencoding_decode_challenge_token() {
- // Typical Meta webhook challenge
- assert_eq!(urlencoding_decode("1234567890"), "1234567890");
- }
-
- #[test]
- fn urlencoding_decode_unicode_percent() {
- // URL-encoded UTF-8 bytes for emoji (simplified test)
- assert_eq!(urlencoding_decode("%41%42%43"), "ABC");
- }
-
+ fn app_state_is_clone() {
+ fn assert_clone() {}
+ assert_clone::();
}
+}
diff --git a/src/identity/aieos.rs b/src/identity/aieos.rs
deleted file mode 100644
index eb0c60b..0000000
--- a/src/identity/aieos.rs
+++ /dev/null
@@ -1,1841 +0,0 @@
-//! AIEOS (AI Entity Object Specification) v1.1 support
-//!
-//! AIEOS is a standardization framework for portable AI identity.
-//! See:
-//!
-//! This module provides:
-//! - Full AIEOS v1.1 schema types
-//! - JSON parsing and validation
-//! - Conversion to `ZeroClaw` system prompt sections
-
-use anyhow::{Context, Result};
-use serde::{Deserialize, Serialize};
-use std::fmt::Write;
-use std::path::Path;
-
-// ══════════════════════════════════════════════════════════════════════════════
-// AIEOS v1.1 Schema Types
-// ══════════════════════════════════════════════════════════════════════════════
-
-/// Root AIEOS entity object
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosEntity {
- /// JSON-LD context (optional, for semantic web compatibility)
- #[serde(rename = "@context", default)]
- pub context: Option,
-
- /// Entity type marker
- #[serde(rename = "@type", default)]
- pub entity_type: Option,
-
- /// Protocol standard info
- #[serde(default)]
- pub standard: Option,
-
- /// Internal tracking metadata
- #[serde(default)]
- pub metadata: Option,
-
- /// Standardized skills and tools
- #[serde(default)]
- pub capabilities: Option,
-
- /// Core biographical data
- #[serde(default)]
- pub identity: Option,
-
- /// Visual descriptors for image generation
- #[serde(default)]
- pub physicality: Option,
-
- /// The "Soul" layer — cognitive weights, traits, moral boundaries
- #[serde(default)]
- pub psychology: Option,
-
- /// How the entity speaks — voice and text style
- #[serde(default)]
- pub linguistics: Option,
-
- /// Origin story, education, occupation
- #[serde(default)]
- pub history: Option,
-
- /// Preferences, hobbies, lifestyle
- #[serde(default)]
- pub interests: Option,
-
- /// Goals and core drives
- #[serde(default)]
- pub motivations: Option,
-}
-
-// ── Context & Standard ───────────────────────────────────────────────────────
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosContext {
- #[serde(default)]
- pub aieos: Option,
- #[serde(default)]
- pub schema: Option,
- #[serde(default)]
- pub xsd: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosStandard {
- #[serde(default)]
- pub protocol: Option,
- #[serde(default)]
- pub version: Option,
- #[serde(default)]
- pub schema_url: Option,
-}
-
-// ── Metadata ─────────────────────────────────────────────────────────────────
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosMetadata {
- #[serde(rename = "@type", default)]
- pub metadata_type: Option,
- #[serde(rename = "@description", default)]
- pub description: Option,
- #[serde(default)]
- pub instance_id: Option,
- #[serde(default)]
- pub instance_version: Option,
- #[serde(default)]
- pub generator: Option,
- #[serde(default)]
- pub created_at: Option,
- #[serde(default)]
- pub last_updated: Option,
-}
-
-// ── Capabilities ─────────────────────────────────────────────────────────────
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosCapabilities {
- #[serde(rename = "@type", default)]
- pub capabilities_type: Option,
- #[serde(rename = "@description", default)]
- pub description: Option,
- #[serde(default)]
- pub skills: Vec,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosSkill {
- #[serde(rename = "@type", default)]
- pub skill_type: Option,
- #[serde(default)]
- pub name: Option,
- #[serde(default)]
- pub description: Option,
- #[serde(default)]
- pub uri: Option,
- #[serde(default)]
- pub version: Option,
- #[serde(default)]
- pub auto_activate: Option,
- #[serde(default)]
- pub priority: Option,
-}
-
-// ── Identity ─────────────────────────────────────────────────────────────────
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosIdentity {
- #[serde(rename = "@type", default)]
- pub identity_type: Option,
- #[serde(rename = "@description", default)]
- pub description: Option,
- #[serde(default)]
- pub names: Option,
- #[serde(default)]
- pub bio: Option,
- #[serde(default)]
- pub origin: Option,
- #[serde(default)]
- pub residence: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosNames {
- #[serde(default)]
- pub first: Option,
- #[serde(default)]
- pub middle: Option,
- #[serde(default)]
- pub last: Option,
- #[serde(default)]
- pub nickname: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosBio {
- #[serde(rename = "@type", default)]
- pub bio_type: Option,
- #[serde(default)]
- pub birthday: Option,
- #[serde(default)]
- pub age_biological: Option,
- #[serde(default)]
- pub age_perceived: Option,
- #[serde(default)]
- pub gender: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosOrigin {
- #[serde(default)]
- pub nationality: Option,
- #[serde(default)]
- pub ethnicity: Option,
- #[serde(default)]
- pub birthplace: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosPlace {
- #[serde(rename = "@type", default)]
- pub place_type: Option,
- #[serde(default)]
- pub city: Option,
- #[serde(default)]
- pub country: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosResidence {
- #[serde(rename = "@type", default)]
- pub residence_type: Option,
- #[serde(default)]
- pub current_city: Option,
- #[serde(default)]
- pub current_country: Option,
- #[serde(default)]
- pub dwelling_type: Option,
-}
-
-// ── Physicality ──────────────────────────────────────────────────────────────
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosPhysicality {
- #[serde(rename = "@type", default)]
- pub physicality_type: Option,
- #[serde(rename = "@description", default)]
- pub description: Option,
- #[serde(default)]
- pub face: Option,
- #[serde(default)]
- pub body: Option,
- #[serde(default)]
- pub style: Option,
- #[serde(default)]
- pub image_prompts: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosFace {
- #[serde(default)]
- pub shape: Option,
- #[serde(default)]
- pub skin: Option,
- #[serde(default)]
- pub eyes: Option,
- #[serde(default)]
- pub hair: Option,
- #[serde(default)]
- pub facial_hair: Option,
- #[serde(default)]
- pub nose: Option,
- #[serde(default)]
- pub mouth: Option,
- #[serde(default)]
- pub distinguishing_features: Vec,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosSkin {
- #[serde(default)]
- pub tone: Option,
- #[serde(default)]
- pub texture: Option,
- #[serde(default)]
- pub details: Vec,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosEyes {
- #[serde(default)]
- pub color: Option,
- #[serde(default)]
- pub shape: Option,
- #[serde(default)]
- pub eyebrows: Option,
- #[serde(default)]
- pub corrective_lenses: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosHair {
- #[serde(default)]
- pub color: Option,
- #[serde(default)]
- pub style: Option,
- #[serde(default)]
- pub texture: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosBody {
- #[serde(default)]
- pub height_cm: Option,
- #[serde(default)]
- pub weight_kg: Option,
- #[serde(default)]
- pub somatotype: Option,
- #[serde(default)]
- pub build_description: Option,
- #[serde(default)]
- pub posture: Option,
- #[serde(default)]
- pub scars_tattoos: Vec,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosStyle {
- #[serde(default)]
- pub aesthetic_archetype: Option,
- #[serde(default)]
- pub clothing_preferences: Vec,
- #[serde(default)]
- pub accessories: Vec,
- #[serde(default)]
- pub color_palette: Vec,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosImagePrompts {
- #[serde(default)]
- pub portrait: Option,
- #[serde(default)]
- pub full_body: Option,
-}
-
-// ── Psychology ───────────────────────────────────────────────────────────────
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosPsychology {
- #[serde(rename = "@type", default)]
- pub psychology_type: Option,
- #[serde(rename = "@description", default)]
- pub description: Option,
- #[serde(default)]
- pub neural_matrix: Option,
- #[serde(default)]
- pub traits: Option,
- #[serde(default)]
- pub moral_compass: Option,
- #[serde(default)]
- pub mental_patterns: Option,
- #[serde(default)]
- pub emotional_profile: Option,
- #[serde(default)]
- pub idiosyncrasies: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosNeuralMatrix {
- #[serde(rename = "@type", default)]
- pub matrix_type: Option,
- #[serde(rename = "@description", default)]
- pub description: Option,
- #[serde(default)]
- pub creativity: Option,
- #[serde(default)]
- pub empathy: Option,
- #[serde(default)]
- pub logic: Option,
- #[serde(default)]
- pub adaptability: Option,
- #[serde(default)]
- pub charisma: Option,
- #[serde(default)]
- pub reliability: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosTraits {
- #[serde(default)]
- pub ocean: Option,
- #[serde(default)]
- pub mbti: Option,
- #[serde(default)]
- pub enneagram: Option,
- #[serde(default)]
- pub temperament: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosOcean {
- #[serde(default)]
- pub openness: Option,
- #[serde(default)]
- pub conscientiousness: Option,
- #[serde(default)]
- pub extraversion: Option,
- #[serde(default)]
- pub agreeableness: Option,
- #[serde(default)]
- pub neuroticism: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosMoralCompass {
- #[serde(default)]
- pub alignment: Option,
- #[serde(default)]
- pub core_values: Vec,
- #[serde(default)]
- pub conflict_resolution_style: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosMentalPatterns {
- #[serde(default)]
- pub decision_making_style: Option,
- #[serde(default)]
- pub attention_span: Option,
- #[serde(default)]
- pub learning_style: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosEmotionalProfile {
- #[serde(default)]
- pub base_mood: Option,
- #[serde(default)]
- pub volatility: Option,
- #[serde(default)]
- pub resilience: Option,
- #[serde(default)]
- pub triggers: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosTriggers {
- #[serde(default)]
- pub joy: Vec,
- #[serde(default)]
- pub anger: Vec,
- #[serde(default)]
- pub sadness: Vec,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosIdiosyncrasies {
- #[serde(default)]
- pub phobias: Vec,
- #[serde(default)]
- pub obsessions: Vec,
- #[serde(default)]
- pub tics: Vec,
-}
-
-// ── Linguistics ──────────────────────────────────────────────────────────────
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosLinguistics {
- #[serde(rename = "@type", default)]
- pub linguistics_type: Option,
- #[serde(rename = "@description", default)]
- pub description: Option,
- #[serde(default)]
- pub voice: Option,
- #[serde(default)]
- pub text_style: Option,
- #[serde(default)]
- pub syntax: Option,
- #[serde(default)]
- pub interaction: Option,
- #[serde(default)]
- pub idiolect: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosVoice {
- #[serde(default)]
- pub tts_config: Option,
- #[serde(default)]
- pub acoustics: Option,
- #[serde(default)]
- pub accent: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosTtsConfig {
- #[serde(default)]
- pub provider: Option,
- #[serde(default)]
- pub voice_id: Option,
- #[serde(default)]
- pub stability: Option,
- #[serde(default)]
- pub similarity_boost: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosAcoustics {
- #[serde(default)]
- pub pitch: Option,
- #[serde(default)]
- pub speed: Option,
- #[serde(default)]
- pub roughness: Option,
- #[serde(default)]
- pub breathiness: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosAccent {
- #[serde(default)]
- pub region: Option,
- #[serde(default)]
- pub strength: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosTextStyle {
- #[serde(default)]
- pub formality_level: Option,
- #[serde(default)]
- pub verbosity_level: Option,
- #[serde(default)]
- pub vocabulary_level: Option,
- #[serde(default)]
- pub slang_usage: Option,
- #[serde(default)]
- pub style_descriptors: Vec,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosSyntax {
- #[serde(default)]
- pub sentence_structure: Option,
- #[serde(default)]
- pub use_contractions: Option,
- #[serde(default)]
- pub active_passive_ratio: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosInteraction {
- #[serde(default)]
- pub turn_taking: Option,
- #[serde(default)]
- pub dominance_score: Option,
- #[serde(default)]
- pub emotional_coloring: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosIdiolect {
- #[serde(default)]
- pub catchphrases: Vec,
- #[serde(default)]
- pub forbidden_words: Vec,
- #[serde(default)]
- pub hesitation_markers: Option,
-}
-
-// ── History ──────────────────────────────────────────────────────────────────
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosHistory {
- #[serde(rename = "@type", default)]
- pub history_type: Option,
- #[serde(rename = "@description", default)]
- pub description: Option,
- #[serde(default)]
- pub origin_story: Option,
- #[serde(default)]
- pub education: Option,
- #[serde(default)]
- pub occupation: Option,
- #[serde(default)]
- pub family: Option,
- #[serde(default)]
- pub key_life_events: Vec,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosEducation {
- #[serde(default)]
- pub level: Option,
- #[serde(default)]
- pub field: Option,
- #[serde(default)]
- pub institution: Option,
- #[serde(default)]
- pub graduation_year: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosOccupation {
- #[serde(default)]
- pub title: Option,
- #[serde(default)]
- pub industry: Option,
- #[serde(default)]
- pub years_experience: Option,
- #[serde(default)]
- pub previous_jobs: Vec,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosFamily {
- #[serde(default)]
- pub relationship_status: Option,
- #[serde(default)]
- pub parents: Option,
- #[serde(default)]
- pub siblings: Option,
- #[serde(default)]
- pub children: Option,
- #[serde(default)]
- pub pets: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosLifeEvent {
- #[serde(default)]
- pub year: Option,
- #[serde(default)]
- pub event: Option,
- #[serde(default)]
- pub impact: Option,
-}
-
-// ── Interests ────────────────────────────────────────────────────────────────
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosInterests {
- #[serde(rename = "@type", default)]
- pub interests_type: Option,
- #[serde(rename = "@description", default)]
- pub description: Option,
- #[serde(default)]
- pub hobbies: Vec,
- #[serde(default)]
- pub favorites: Option,
- #[serde(default)]
- pub aversions: Vec,
- #[serde(default)]
- pub lifestyle: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosFavorites {
- #[serde(default)]
- pub music_genre: Option,
- #[serde(default)]
- pub book: Option,
- #[serde(default)]
- pub movie: Option,
- #[serde(default)]
- pub color: Option,
- #[serde(default)]
- pub food: Option,
- #[serde(default)]
- pub season: Option,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosLifestyle {
- #[serde(default)]
- pub diet: Option,
- #[serde(default)]
- pub sleep_schedule: Option,
- #[serde(default)]
- pub digital_habits: Option,
-}
-
-// ── Motivations ──────────────────────────────────────────────────────────────
-
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct AieosMotivations {
- #[serde(rename = "@type", default)]
- pub motivations_type: Option,
- #[serde(rename = "@description", default)]
- pub description: Option,
- #[serde(default)]
- pub core_drive: Option