//! # quicproquo-bot — Bot SDK for E2E encrypted messaging //! //! Build automated agents that run on the quicproquo network with full MLS //! end-to-end encryption. The bot SDK wraps the client library into a simple //! polling-based API: connect, authenticate, send, receive. //! //! ## Quick start //! //! ```rust,no_run //! use quicproquo_bot::{Bot, BotConfig}; //! //! #[tokio::main] //! async fn main() -> anyhow::Result<()> { //! let config = BotConfig::new("127.0.0.1:7000", "bot-user", "bot-password") //! .ca_cert("server-cert.der") //! .state_path("bot-state.bin"); //! //! let bot = Bot::connect(config).await?; //! //! // Send a DM //! bot.send_dm("alice", "Hello from bot!").await?; //! //! // Poll for messages //! loop { //! for msg in bot.receive(5000).await? { //! println!("{}: {}", msg.sender, msg.text); //! if msg.text.starts_with("!echo ") { //! bot.send_dm(&msg.sender, &msg.text[6..]).await?; //! } //! } //! } //! } //! ``` //! //! ## Pipe mode (stdin/stdout JSON lines) //! //! The bot SDK also supports non-interactive pipe mode for shell integration: //! //! ```bash //! # Send via pipe //! echo '{"to":"alice","text":"hello"}' | qpq pipe --state bot.bin //! //! # Receive via pipe (JSON lines to stdout) //! qpq pipe --recv --state bot.bin //! ``` use std::path::PathBuf; use std::sync::Arc; use anyhow::Context; use tokio::task::LocalSet; use quicproquo_client::{connect_node, init_auth, opaque_login, resolve_user, ClientAuth}; use quicproquo_core::IdentityKeypair; /// Configuration for connecting a bot to a quicproquo server. #[derive(Clone, Debug)] pub struct BotConfig { /// Server address (host:port). pub server: String, /// Path to the server's CA certificate (DER format). pub ca_cert: PathBuf, /// TLS server name (defaults to "localhost"). pub server_name: String, /// Bot's username for OPAQUE authentication. pub username: String, /// Bot's password for OPAQUE authentication. pub password: String, /// Path to the bot's encrypted state file. pub state_path: PathBuf, /// Password for the encrypted state file (None = unencrypted). pub state_password: Option, /// Device ID reported to the server. pub device_id: Option, } impl BotConfig { /// Create a new bot configuration with required fields. pub fn new(server: &str, username: &str, password: &str) -> Self { Self { server: server.to_string(), ca_cert: PathBuf::from("server-cert.der"), server_name: "localhost".to_string(), username: username.to_string(), password: password.to_string(), state_path: PathBuf::from("bot-state.bin"), state_password: None, device_id: None, } } /// Set the CA certificate path. pub fn ca_cert(mut self, path: &str) -> Self { self.ca_cert = PathBuf::from(path); self } /// Set the TLS server name for certificate validation. pub fn server_name(mut self, name: &str) -> Self { self.server_name = name.to_string(); self } /// Set the state file path. pub fn state_path(mut self, path: &str) -> Self { self.state_path = PathBuf::from(path); self } /// Set the state file encryption password. pub fn state_password(mut self, pwd: &str) -> Self { self.state_password = Some(pwd.to_string()); self } /// Set the device ID. pub fn device_id(mut self, id: &str) -> Self { self.device_id = Some(id.to_string()); self } } /// A received message from the quicproquo network. #[derive(Clone, Debug, serde::Serialize)] pub struct Message { /// The sender's username (or "unknown" if resolution failed). pub sender: String, /// The decrypted plaintext message content. pub text: String, /// Server-assigned sequence number. pub seq: u64, } /// A bot connected to a quicproquo server. /// /// The bot maintains its identity and MLS group state. Each call to /// `send_dm` or `receive` opens a fresh QUIC connection (stateless /// reconnect pattern — same as the CLI client). pub struct Bot { config: BotConfig, identity: Arc, } impl Bot { /// Connect to a quicproquo server and authenticate. /// /// Loads or creates an identity from the state file, connects via QUIC/TLS, /// and performs OPAQUE password authentication. pub async fn connect(config: BotConfig) -> anyhow::Result { let state = quicproquo_client::client::state::load_or_init_state( &config.state_path, config.state_password.as_deref(), ) .context("load or init bot state")?; let identity = Arc::new(IdentityKeypair::from_seed(state.identity_seed)); // Authenticate on the first connection. let local = LocalSet::new(); let cfg = config.clone(); let id = Arc::clone(&identity); local .run_until(async { let client = connect_node(&cfg.server, &cfg.ca_cert, &cfg.server_name).await?; let pk = id.public_key_bytes(); let token = opaque_login( &client, &cfg.username, &cfg.password, &pk, ) .await .context("OPAQUE login")?; init_auth(ClientAuth::from_raw(token, cfg.device_id.clone())); tracing::info!(username = %cfg.username, server = %cfg.server, "bot authenticated"); Ok::<(), anyhow::Error>(()) }) .await?; Ok(Self { config, identity }) } /// Send a plaintext message to a peer by username. /// /// Resolves the username to an identity key, then encrypts via MLS /// and delivers through the server. pub async fn send_dm(&self, peer_username: &str, text: &str) -> anyhow::Result<()> { // Resolve username → identity key hex so we send to the specific peer. let peer_key = self .resolve_user(peer_username) .await .context("resolve peer username")?; let peer_key_hex = hex::encode(&peer_key); quicproquo_client::cmd_send( &self.config.state_path, &self.config.server, &self.config.ca_cert, &self.config.server_name, Some(&peer_key_hex), false, text, self.config.state_password.as_deref(), ) .await .context("send message")?; Ok(()) } /// Receive pending messages, waiting up to `timeout_ms` milliseconds. /// /// Returns decrypted application messages. MLS control messages (commits, /// welcomes) are processed internally but not returned. pub async fn receive(&self, timeout_ms: u64) -> anyhow::Result> { let plaintexts = quicproquo_client::receive_pending_plaintexts( &self.config.state_path, &self.config.server, &self.config.ca_cert, &self.config.server_name, timeout_ms, self.config.state_password.as_deref(), ) .await?; let messages: Vec = plaintexts .into_iter() .enumerate() .map(|(i, plaintext)| Message { sender: "peer".to_string(), // TODO: resolve from MLS group roster text: String::from_utf8_lossy(&plaintext).to_string(), seq: i as u64, }) .collect(); Ok(messages) } /// Receive raw plaintext bytes (for binary protocols or non-UTF-8 content). pub async fn receive_raw(&self, timeout_ms: u64) -> anyhow::Result>> { quicproquo_client::receive_pending_plaintexts( &self.config.state_path, &self.config.server, &self.config.ca_cert, &self.config.server_name, timeout_ms, self.config.state_password.as_deref(), ) .await } /// Resolve a username to a 32-byte identity key. pub async fn resolve_user(&self, username: &str) -> anyhow::Result> { let local = LocalSet::new(); let cfg = self.config.clone(); let username = username.to_string(); local .run_until(async { let client = connect_node(&cfg.server, &cfg.ca_cert, &cfg.server_name).await?; let key = resolve_user(&client, &username) .await? .ok_or_else(|| anyhow::anyhow!("user not found: {username}"))?; Ok(key) }) .await } /// Get the bot's own username. pub fn username(&self) -> &str { &self.config.username } /// Get the bot's identity public key (32 bytes, Ed25519). pub fn identity_key(&self) -> [u8; 32] { self.identity.public_key_bytes() } /// Get the bot's identity key as a hex string. pub fn identity_key_hex(&self) -> String { hex::encode(self.identity.public_key_bytes()) } } /// Read JSON commands from stdin and process them. /// /// Each line should be a JSON object with: /// - `{"action": "send", "to": "username", "text": "message"}` /// - `{"action": "recv", "timeout_ms": 5000}` /// - `{"action": "resolve", "username": "alice"}` /// /// Results are written to stdout as JSON lines. pub async fn run_pipe_mode(bot: &Bot) -> anyhow::Result<()> { use tokio::io::{AsyncBufReadExt, BufReader}; let stdin = BufReader::new(tokio::io::stdin()); let mut lines = stdin.lines(); while let Ok(Some(line)) = lines.next_line().await { let line = line.trim().to_string(); if line.is_empty() { continue; } let cmd: serde_json::Value = match serde_json::from_str(&line) { Ok(v) => v, Err(e) => { let err = serde_json::json!({"error": format!("invalid JSON: {e}")}); println!("{err}"); continue; } }; let action = cmd["action"].as_str().unwrap_or(""); let result = match action { "send" => { let to = cmd["to"].as_str().unwrap_or(""); let text = cmd["text"].as_str().unwrap_or(""); match bot.send_dm(to, text).await { Ok(()) => serde_json::json!({"status": "ok", "action": "send"}), Err(e) => serde_json::json!({"error": format!("{e:#}")}), } } "recv" => { let timeout = cmd["timeout_ms"].as_u64().unwrap_or(5000); match bot.receive(timeout).await { Ok(msgs) => serde_json::json!({"status": "ok", "messages": msgs}), Err(e) => serde_json::json!({"error": format!("{e:#}")}), } } "resolve" => { let username = cmd["username"].as_str().unwrap_or(""); match bot.resolve_user(username).await { Ok(key) => serde_json::json!({ "status": "ok", "identity_key": hex::encode(&key), }), Err(e) => serde_json::json!({"error": format!("{e:#}")}), } } _ => serde_json::json!({"error": format!("unknown action: {action}")}), }; println!("{result}"); } Ok(()) }