diff --git a/Cargo.lock b/Cargo.lock index 280e305..4b44e87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1053,6 +1053,7 @@ version = "0.0.1" dependencies = [ "clap", "libremetaverse", + "tokio", ] [[package]] @@ -1936,6 +1937,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -2173,6 +2184,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", diff --git a/README.md b/README.md index 00e4cc0..beb6942 100644 --- a/README.md +++ b/README.md @@ -485,9 +485,10 @@ documented in the ### Milestone 11 -The native `osd-inspector` program is the first completed program target. It -inspects, validates, and converts bounded JSON, XML, binary, and notation LLSD, -and performs deterministic Primitive-to-OSD round trips entirely through the -public Rust APIs. Its command aliases, standard-stream behavior, exit codes, -resource limits, isolated CLI tests, and the status of every remaining program +The native `osd-inspector` and `simple-bot` programs are complete. +`osd-inspector` validates and converts bounded LLSD and performs deterministic +Primitive-to-OSD round trips. `simple-bot` provides native async login, IM and +local-chat commands, movement and animation calls, bounded fake-grid +conversations, secret redaction, and cancellation-safe logout. Their command +surfaces, limits, isolated CLI tests, and the status of every remaining program are documented in the [`native programs guide`](programs/README.md). diff --git a/api/SHIM-COVERAGE.md b/api/SHIM-COVERAGE.md index 5750e25..24a1490 100644 --- a/api/SHIM-COVERAGE.md +++ b/api/SHIM-COVERAGE.md @@ -4,7 +4,7 @@ Generated by `python3 tools/generate_api_shims.py`; do not edit by hand. | Assembly | Types | Members | Status | |---|---:|---:|---| -| `LibreMetaverse` | 2,711 | 27,281 | native implementation: 401 types / 17,025 members; remaining surface is callable failure-only shims | +| `LibreMetaverse` | 2,711 | 27,281 | native implementation: 401 types / 17,026 members; remaining surface is callable failure-only shims | | `LibreMetaverse.Imaging.Abstractions` | 3 | 20 | native implementation: 3 types / 20 members; no generated shims remain | | `LibreMetaverse.Imaging.Skia` | 1 | 3 | native implementation: 1 type / 3 members; no generated shims remain | | `LibreMetaverse.LslTools` | 164 | 768 | native implementation: 164 types / 768 members; no generated shims remain | diff --git a/crates/libremetaverse/src/animation.rs b/crates/libremetaverse/src/animation.rs index f99ffa5..cff504c 100644 --- a/crates/libremetaverse/src/animation.rs +++ b/crates/libremetaverse/src/animation.rs @@ -3,7 +3,7 @@ #![allow(clippy::needless_pass_by_value, clippy::unused_self)] // Mapped C# signatures are fixed. use libremetaverse_types::compat::Object; -use libremetaverse_types::{Error, Vector3}; +use libremetaverse_types::{Error, UUID, Vector3}; use std::hash::{Hash, Hasher}; const MAX_ANIMATION_BYTES: usize = 32 * 1024 * 1024; @@ -11,6 +11,11 @@ const MAX_JOINTS: usize = 512; const MAX_KEYS_PER_JOINT: usize = 10_000; const MAX_JOINT_NAME_BYTES: usize = 255; +pub(crate) fn dance1() -> UUID { + UUID::new_with_string("b68a3d7c-de9e-fc87-eec8-543d787e5b0d".into()) + .expect("built-in DANCE1 UUID must be valid") +} + #[derive(Clone, Debug)] pub struct BinBVHJointKey { pub key_element: Vector3, diff --git a/crates/libremetaverse/src/generated.rs b/crates/libremetaverse/src/generated.rs index 1084e6b..1d4521c 100644 --- a/crates/libremetaverse/src/generated.rs +++ b/crates/libremetaverse/src/generated.rs @@ -2350,7 +2350,7 @@ impl Animations { } /// C# member: `F:LibreMetaverse.Animations.DANCE1`. pub fn dance1() -> libremetaverse_types::UUID { - libremetaverse_types::unimplemented_api!("F:LibreMetaverse.Animations.DANCE1") + crate::animation::dance1() } /// C# member: `F:LibreMetaverse.Animations.DANCE2`. pub fn dance2() -> libremetaverse_types::UUID { diff --git a/programs/Cargo.toml b/programs/Cargo.toml index 463cd61..07cd3e2 100644 --- a/programs/Cargo.toml +++ b/programs/Cargo.toml @@ -9,6 +9,7 @@ publish = false [dependencies] clap = { version = "4.5", features = ["derive"] } libremetaverse = { path = "../crates/libremetaverse", default-features = false } +tokio = { version = "1.47", features = ["macros", "rt-multi-thread", "signal", "sync", "time"] } [lints] workspace = true diff --git a/programs/README.md b/programs/README.md index e1feefe..94aed1c 100644 --- a/programs/README.md +++ b/programs/README.md @@ -8,7 +8,7 @@ here so a source entry is never mistaken for a completed port. | Binary | Upstream project | Status | | --- | --- | --- | | `osd-inspector` | OSDInspector | Implemented and tested offline | -| `simple-bot` | SimpleBot | Pending milestone 11 issue #86 | +| `simple-bot` | SimpleBot | Implemented with live and deterministic fake-grid modes | | `packet-dump` | PacketDump | Pending milestone 11 issue #87 | | `prim-inspector` | PrimInspector | Pending milestone 11 issue #88 | | `inventory-explorer` | InventoryExplorer | Pending milestone 11 issue #89 | @@ -60,3 +60,43 @@ with: cargo test -p libremetaverse-programs --test osd_inspector_cli --locked cargo test --manifest-path tests/compat/Cargo.toml --test structured_data --locked ``` + +## SimpleBot + +`simple-bot` is an asynchronous native Rust client with the command surface of +the upstream example. A live session accepts the original positional +credentials, or reads them from the environment: + +```text +simple-bot FIRSTNAME LASTNAME PASSWORD [--login-uri URL] +GRID_FIRST_NAME=... GRID_LAST_NAME=... GRID_PASSWORD=... simple-bot +``` + +`GRID_LOGIN_URL` supplies the endpoint when `--login-uri` is absent, and +`--login-timeout-seconds` bounds login to 30 seconds by default. Credentials, +authorization values, capability URLs, and token values are redacted from +output. After login the bot answers `help`/`?`, `where`/`location`, `sit`, +`stand`, `dance`, `fly`, `walk`, `jump`, and `hello`/`hi`/`hey` instant +messages. It also greets other avatars that say hello in local chat. Ctrl-C +cancels pending greetings and login work, releases an in-progress jump, +unsubscribes event handlers, logs out, and disposes the client before exit. + +For offline validation, `--fake-script FILE` runs the same command handlers +against a deterministic fake grid. Scripts contain no credentials. Blank +lines and lines beginning with `#` are ignored; remaining lines use one of: + +```text +imsource-uuidsource-namemessage +chatsource-uuidsource-namemessage +statusmessage +``` + +The reader accepts at most 1 MiB, 1,024 events, 256-byte names, and 4,096-byte +messages. The fake transcript records every client call, uses the real DANCE1 +UUID, and finishes with zero active tasks and sockets. Run the issue-focused +suite and the related runtime compatibility cases with: + +```sh +cargo test -p libremetaverse-programs --test simple_bot_cli --locked +cargo test --manifest-path tests/compat/Cargo.toml --test core_runtime_shims --locked +``` diff --git a/programs/src/bin/simple_bot.rs b/programs/src/bin/simple_bot.rs index aa4f7ac..986bc76 100644 --- a/programs/src/bin/simple_bot.rs +++ b/programs/src/bin/simple_bot.rs @@ -1,3 +1,3 @@ fn main() -> std::process::ExitCode { - libremetaverse_programs::pending_program("SimpleBot") + libremetaverse_programs::simple_bot::main_entry() } diff --git a/programs/src/lib.rs b/programs/src/lib.rs index 2303805..cb3711c 100644 --- a/programs/src/lib.rs +++ b/programs/src/lib.rs @@ -2,5 +2,6 @@ pub mod commands; pub mod osd_inspector; +pub mod simple_bot; pub use libremetaverse::shim::pending_program; diff --git a/programs/src/simple_bot.rs b/programs/src/simple_bot.rs new file mode 100644 index 0000000..a5221cf --- /dev/null +++ b/programs/src/simple_bot.rs @@ -0,0 +1,964 @@ +//! Async, cancellation-safe native implementation of the `SimpleBot` example. + +use clap::Parser; +use libremetaverse::types::compat::{CancellationToken, CancellationTokenSource, Subscription}; +use libremetaverse::types::{UUID, Vector3}; +use libremetaverse::{ + AgentManager, Animations, ChatEventArgs, ChatType, DisconnectedEventArgs, GridClient, + InstantMessageEventArgs, LoginProgressEventArgs, NetworkManager, +}; +use std::fmt; +use std::fs::File; +use std::io::{self, BufRead, BufReader, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::sync::mpsc; +use tokio::task::JoinSet; + +pub const EXIT_SUCCESS: u8 = 0; +pub const EXIT_USAGE: u8 = 2; +pub const EXIT_INPUT: u8 = 3; +pub const EXIT_CLIENT: u8 = 4; + +const MAX_SCRIPT_BYTES: u64 = 1024 * 1024; +const MAX_SCRIPT_EVENTS: usize = 1024; +const MAX_NAME_BYTES: usize = 256; +const MAX_MESSAGE_BYTES: usize = 4096; +const EVENT_QUEUE_CAPACITY: usize = 256; +const JUMP_DURATION: Duration = Duration::from_millis(500); +const GREETING_DELAY: Duration = Duration::from_millis(750); +const BOT_ID_TEXT: &str = "11111111-2222-3333-4444-555555555555"; +const FAKE_REGION: &str = "Scripted Test Region"; + +#[derive(Parser)] +#[command( + name = "simple-bot", + version, + about = "Run the native LibreMetaverse SimpleBot", + long_about = None, + arg_required_else_help = true, + after_help = "Commands via IM:\n help, ? Show available commands\n where, location Report current location\n sit Sit on the ground\n stand Stand up\n dance Start the DANCE1 animation\n fly Start flying\n walk Stop flying\n jump Jump briefly\n hello, hi, hey Return a greeting" +)] +struct Cli { + /// Avatar first name. May also be supplied as `GRID_FIRST_NAME`. + #[arg(value_name = "FIRSTNAME")] + first_name: Option, + + /// Avatar last name. May also be supplied as `GRID_LAST_NAME`. + #[arg(value_name = "LASTNAME")] + last_name: Option, + + /// Avatar password. May also be supplied as `GRID_PASSWORD`. + #[arg(value_name = "PASSWORD")] + password: Option, + + /// Run a bounded tab-separated fake-grid conversation instead of logging in. + #[arg(long, value_name = "FILE")] + fake_script: Option, + + /// Override the login endpoint. `GRID_LOGIN_URL` is used when this is absent. + #[arg(long, value_name = "URL")] + login_uri: Option, + + /// Maximum time allowed for login. + #[arg(long, default_value_t = 30, value_name = "SECONDS", value_parser = clap::value_parser!(u64).range(1..=300))] + login_timeout_seconds: u64, +} + +struct Credentials { + first_name: String, + last_name: String, + password: String, + login_uri: Option, + login_timeout: Duration, +} + +#[derive(Debug)] +enum ProgramError { + Usage(&'static str), + Io { action: String, source: io::Error }, + InvalidScript { line: usize, reason: &'static str }, + Client(&'static str), + LoginFailed, + LoginTimedOut, + Signal, +} + +impl ProgramError { + const fn exit_code(&self) -> u8 { + match self { + Self::Usage(_) => EXIT_USAGE, + Self::Io { .. } | Self::InvalidScript { .. } => EXIT_INPUT, + Self::Client(_) | Self::LoginFailed | Self::LoginTimedOut | Self::Signal => EXIT_CLIENT, + } + } +} + +impl fmt::Display for ProgramError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Usage(message) => formatter.write_str(message), + Self::Io { action, source } => write!(formatter, "{action}: {source}"), + Self::InvalidScript { line, reason } => { + write!( + formatter, + "invalid fake-grid script at line {line}: {reason}" + ) + } + Self::Client(operation) => { + write!(formatter, "native client operation failed: {operation}") + } + Self::LoginFailed => formatter.write_str("login failed"), + Self::LoginTimedOut => formatter.write_str("login timed out"), + Self::Signal => formatter.write_str("could not install the Ctrl-C handler"), + } + } +} + +trait BotLogger: Send + Sync + 'static { + fn line(&self, line: String); +} + +struct ConsoleLogger; + +impl BotLogger for ConsoleLogger { + fn line(&self, line: String) { + println!("{}", redact_text(&line)); + } +} + +#[derive(Default)] +struct BufferedLogger { + lines: Mutex>, +} + +impl BufferedLogger { + fn finish(&self, output: &mut dyn Write) -> Result<(), ProgramError> { + for line in lock(&self.lines).iter() { + writeln!(output, "{line}").map_err(|source| ProgramError::Io { + action: "writing standard output".into(), + source, + })?; + } + Ok(()) + } +} + +impl BotLogger for BufferedLogger { + fn line(&self, line: String) { + lock(&self.lines).push(redact_text(&line)); + } +} + +trait BotBackend: Send + Sync + 'static { + fn agent_id(&self) -> UUID; + fn region_name(&self) -> String; + fn position(&self) -> Vector3; + fn instant_message(&self, target: UUID, message: String) -> Result<(), &'static str>; + fn local_chat(&self, message: String) -> Result<(), &'static str>; + fn sit_on_ground(&self) -> Result<(), &'static str>; + fn stand(&self) -> Result<(), &'static str>; + fn dance(&self) -> Result<(), &'static str>; + fn fly(&self, start: bool) -> Result<(), &'static str>; + fn jump(&self, start: bool) -> Result<(), &'static str>; +} + +#[derive(Clone)] +enum InboundEvent { + InstantMessage { + source_id: UUID, + source_name: String, + message: String, + }, + LocalChat { + source_id: UUID, + source_name: String, + message: String, + }, + Status(String), +} + +enum DelayedAction { + ReleaseJump, + Greeting(String), +} + +struct LiveGrid { + client: Mutex, + network: NetworkManager, + subscriptions: Mutex>, + shutdown: AtomicBool, + dropped_events: Arc, +} + +impl LiveGrid { + fn new(client: GridClient, network: NetworkManager) -> Self { + Self { + client: Mutex::new(client), + network, + subscriptions: Mutex::new(Vec::new()), + shutdown: AtomicBool::new(false), + dropped_events: Arc::new(AtomicUsize::new(0)), + } + } + + fn install_subscriptions(self: &Arc, sender: &mpsc::Sender) { + let mut subscriptions = Vec::with_capacity(4); + let im_sender = sender.clone(); + let dropped = Arc::clone(&self.dropped_events); + subscriptions.push(self.with_agent(|agent| { + agent.subscribe_im(Arc::new(move |event: InstantMessageEventArgs| { + let im = event.im(); + send_event( + &im_sender, + InboundEvent::InstantMessage { + source_id: im.from_agent_id, + source_name: im.from_agent_name, + message: im.message, + }, + &dropped, + ); + })) + })); + + let chat_sender = sender.clone(); + let dropped = Arc::clone(&self.dropped_events); + subscriptions.push(self.with_agent(|agent| { + agent.subscribe_chat_from_simulator(Arc::new(move |event: ChatEventArgs| { + send_event( + &chat_sender, + InboundEvent::LocalChat { + source_id: event.source_id(), + source_name: event.from_name(), + message: event.message(), + }, + &dropped, + ); + })) + })); + + let progress_sender = sender.clone(); + let dropped = Arc::clone(&self.dropped_events); + subscriptions.push(self.network.subscribe_login_progress(Arc::new( + move |event: LoginProgressEventArgs| { + send_event( + &progress_sender, + InboundEvent::Status(format!( + "Login {:?}: {}", + event.status(), + event.message() + )), + &dropped, + ); + }, + ))); + + let disconnected_sender = sender.clone(); + let dropped = Arc::clone(&self.dropped_events); + subscriptions.push(self.network.subscribe_disconnected(Arc::new( + move |event: DisconnectedEventArgs| { + send_event( + &disconnected_sender, + InboundEvent::Status(format!( + "Disconnected: {:?} - {}", + event.reason(), + event.message() + )), + &dropped, + ); + }, + ))); + *lock(&self.subscriptions) = subscriptions; + } + + fn with_agent(&self, action: impl FnOnce(&mut AgentManager) -> T) -> T { + let mut client = lock(&self.client); + action(client.self_()) + } + + fn shutdown(&self) { + if self.shutdown.swap(true, Ordering::AcqRel) { + return; + } + lock(&self.subscriptions).clear(); + let _ = self.network.logout_with_method(); + let mut client = lock(&self.client); + let _ = client.self_().dispose(); + let _ = client.dispose_with_method(); + } +} + +impl BotBackend for LiveGrid { + fn agent_id(&self) -> UUID { + self.with_agent(|agent| agent.agent_id()) + } + + fn region_name(&self) -> String { + self.network + .current_sim() + .map_or_else(|| "unknown".into(), |simulator| simulator.name.clone()) + } + + fn position(&self) -> Vector3 { + self.with_agent(|agent| agent.sim_position()) + } + + fn instant_message(&self, target: UUID, message: String) -> Result<(), &'static str> { + self.with_agent(|agent| agent.instant_message_with_uuid_string(target, message)) + .map_err(|_| "send instant message") + } + + fn local_chat(&self, message: String) -> Result<(), &'static str> { + self.with_agent(|agent| agent.chat(message, 0, ChatType::Normal, Some(false))) + .map_err(|_| "send local chat") + } + + fn sit_on_ground(&self) -> Result<(), &'static str> { + self.with_agent(|agent| agent.sit_on_ground()) + .map_err(|_| "sit on ground") + } + + fn stand(&self) -> Result<(), &'static str> { + self.with_agent(|agent| agent.stand()) + .map(|_| ()) + .map_err(|_| "stand") + } + + fn dance(&self) -> Result<(), &'static str> { + self.with_agent(|agent| agent.animation_start(Animations::dance1(), true)) + .map_err(|_| "start DANCE1 animation") + } + + fn fly(&self, start: bool) -> Result<(), &'static str> { + self.with_agent(|agent| agent.fly(start)) + .map_err(|_| if start { "start flying" } else { "stop flying" }) + } + + fn jump(&self, start: bool) -> Result<(), &'static str> { + self.with_agent(|agent| agent.jump(start)) + .map_err(|_| if start { "start jump" } else { "release jump" }) + } +} + +struct ScriptBackend { + calls: Mutex>, + agent_id: UUID, +} + +impl ScriptBackend { + fn new() -> Result { + Ok(Self { + calls: Mutex::new(Vec::new()), + agent_id: parse_uuid(BOT_ID_TEXT, 0)?, + }) + } + + fn record(&self, call: String) { + lock(&self.calls).push(call); + } + + fn drain_calls(&self, logger: &dyn BotLogger) { + for call in std::mem::take(&mut *lock(&self.calls)) { + logger.line(call); + } + } +} + +impl BotBackend for ScriptBackend { + fn agent_id(&self) -> UUID { + self.agent_id + } + + fn region_name(&self) -> String { + FAKE_REGION.into() + } + + fn position(&self) -> Vector3 { + Vector3 { + x: 128.0, + y: 64.0, + z: 25.0, + } + } + + fn instant_message(&self, target: UUID, message: String) -> Result<(), &'static str> { + self.record(format!("CALL instant-message {target} {message}")); + Ok(()) + } + + fn local_chat(&self, message: String) -> Result<(), &'static str> { + self.record(format!("CALL chat channel=0 type=Normal {message}")); + Ok(()) + } + + fn sit_on_ground(&self) -> Result<(), &'static str> { + self.record("CALL sit-on-ground".into()); + Ok(()) + } + + fn stand(&self) -> Result<(), &'static str> { + self.record("CALL stand".into()); + Ok(()) + } + + fn dance(&self) -> Result<(), &'static str> { + self.record(format!( + "CALL animation-start {} reliable=true", + Animations::dance1() + )); + Ok(()) + } + + fn fly(&self, start: bool) -> Result<(), &'static str> { + self.record(format!("CALL fly {start}")); + Ok(()) + } + + fn jump(&self, start: bool) -> Result<(), &'static str> { + self.record(format!("CALL jump {start}")); + Ok(()) + } +} + +#[must_use] +pub fn main_entry() -> ExitCode { + let cli = Cli::parse(); + let Ok(runtime) = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + else { + eprintln!("simple-bot: could not initialize the async runtime"); + return ExitCode::from(EXIT_CLIENT); + }; + match runtime.block_on(run(cli)) { + Ok(()) => ExitCode::from(EXIT_SUCCESS), + Err(error) => { + eprintln!("simple-bot: {error}"); + ExitCode::from(error.exit_code()) + } + } +} + +async fn run(cli: Cli) -> Result<(), ProgramError> { + if let Some(script) = cli.fake_script { + if cli.first_name.is_some() || cli.last_name.is_some() || cli.password.is_some() { + return Err(ProgramError::Usage( + "credentials cannot be combined with --fake-script", + )); + } + let stdout = io::stdout(); + return run_fake_script(&script, &mut stdout.lock()); + } + let credentials = resolve_credentials(cli)?; + run_live(credentials).await +} + +fn resolve_credentials(cli: Cli) -> Result { + let first_name = cli + .first_name + .or_else(|| std::env::var("GRID_FIRST_NAME").ok()) + .filter(|value| !value.is_empty()) + .ok_or(ProgramError::Usage( + "FIRSTNAME is required (or set GRID_FIRST_NAME)", + ))?; + let last_name = cli + .last_name + .or_else(|| std::env::var("GRID_LAST_NAME").ok()) + .filter(|value| !value.is_empty()) + .ok_or(ProgramError::Usage( + "LASTNAME is required (or set GRID_LAST_NAME)", + ))?; + let password = cli + .password + .or_else(|| std::env::var("GRID_PASSWORD").ok()) + .filter(|value| !value.is_empty()) + .ok_or(ProgramError::Usage( + "PASSWORD is required (or set GRID_PASSWORD)", + ))?; + let login_uri = cli + .login_uri + .or_else(|| std::env::var("GRID_LOGIN_URL").ok()) + .filter(|value| !value.is_empty()); + Ok(Credentials { + first_name, + last_name, + password, + login_uri, + login_timeout: Duration::from_secs(cli.login_timeout_seconds), + }) +} + +async fn run_live(mut credentials: Credentials) -> Result<(), ProgramError> { + let client = GridClient::new().map_err(|_| ProgramError::Client("construct GridClient"))?; + let network = client.network(); + let mut login = network + .default_login_params( + std::mem::take(&mut credentials.first_name), + std::mem::take(&mut credentials.last_name), + std::mem::take(&mut credentials.password), + "SimpleBot".into(), + env!("CARGO_PKG_VERSION").into(), + ) + .map_err(|_| ProgramError::Client("build login parameters"))?; + if let Some(uri) = credentials.login_uri.take() { + login.uri = uri; + } + let login_timeout = credentials.login_timeout; + let grid = Arc::new(LiveGrid::new(client, network.clone())); + let logger = Arc::new(ConsoleLogger); + let (sender, receiver) = mpsc::channel(EVENT_QUEUE_CAPACITY); + grid.install_subscriptions(&sender); + let cancellation = CancellationTokenSource::new(); + let worker = tokio::spawn(event_loop( + Arc::clone(&grid), + Arc::clone(&logger), + receiver, + cancellation.token(), + )); + + logger.line("Logging in...".into()); + let login_cancellation = CancellationTokenSource::new(); + let login_result = tokio::select! { + result = network.login_with_login_params_cancellation_token( + login, + Some(login_cancellation.token()), + ) => result.map(Some).map_err(|_| ProgramError::LoginFailed), + () = tokio::time::sleep(login_timeout) => { + login_cancellation.cancel(); + let _ = network.abort_login(); + Err(ProgramError::LoginTimedOut) + } + signal = tokio::signal::ctrl_c() => { + login_cancellation.cancel(); + let _ = network.abort_login(); + signal.map_err(|_| ProgramError::Signal)?; + Ok(None) + } + }; + + let success = match login_result { + Ok(Some(success)) => success, + Ok(None) => { + finish_live(grid, sender, cancellation, worker).await; + return Ok(()); + } + Err(error) => { + finish_live(grid, sender, cancellation, worker).await; + return Err(error); + } + }; + if !success { + finish_live(grid, sender, cancellation, worker).await; + return Err(ProgramError::LoginFailed); + } + + logger.line(format!("Logged in to {}", grid.region_name())); + logger.line(format!("Position: {}", grid.position().to_string())); + logger.line("Bot is ready. Send an IM with 'help'; press Ctrl-C to logout.".into()); + tokio::signal::ctrl_c() + .await + .map_err(|_| ProgramError::Signal)?; + finish_live(grid, sender, cancellation, worker).await; + Ok(()) +} + +async fn finish_live( + grid: Arc, + sender: mpsc::Sender, + cancellation: CancellationTokenSource, + worker: tokio::task::JoinHandle<()>, +) { + cancellation.cancel(); + drop(sender); + let _ = worker.await; + let dropped = grid.dropped_events.load(Ordering::Acquire); + grid.shutdown(); + if dropped > 0 { + eprintln!("simple-bot: dropped {dropped} events because the bounded queue was full"); + } +} + +async fn event_loop( + backend: Arc, + logger: Arc, + mut receiver: mpsc::Receiver, + cancellation: CancellationToken, +) { + let mut delayed = JoinSet::new(); + loop { + tokio::select! { + () = cancellation.cancelled() => break, + event = receiver.recv() => { + let Some(event) = event else { break }; + if let Some(action) = handle_event(backend.as_ref(), logger.as_ref(), event) { + spawn_delayed( + &mut delayed, + Arc::clone(&backend), + Arc::clone(&logger), + cancellation.clone(), + action, + ); + } + } + result = delayed.join_next(), if !delayed.is_empty() => { + let _ = result; + } + } + } + receiver.close(); + while delayed.join_next().await.is_some() {} +} + +fn spawn_delayed( + tasks: &mut JoinSet<()>, + backend: Arc, + logger: Arc, + cancellation: CancellationToken, + action: DelayedAction, +) { + tasks.spawn(async move { + match action { + DelayedAction::ReleaseJump => { + tokio::select! { + () = tokio::time::sleep(JUMP_DURATION) => {} + () = cancellation.cancelled() => {} + } + if let Err(error) = backend.jump(false) { + logger.line(format!("Error: {error}")); + } + } + DelayedAction::Greeting(message) => { + let send = tokio::select! { + () = tokio::time::sleep(GREETING_DELAY) => true, + () = cancellation.cancelled() => false, + }; + if send && let Err(error) = backend.local_chat(message) { + logger.line(format!("Error: {error}")); + } + } + } + }); +} + +fn handle_event( + backend: &dyn BotBackend, + logger: &dyn BotLogger, + event: InboundEvent, +) -> Option { + match event { + InboundEvent::InstantMessage { + source_id, + source_name, + message, + } => handle_instant_message(backend, logger, source_id, &source_name, &message), + InboundEvent::LocalChat { + source_id, + source_name, + message, + } => handle_local_chat(backend, logger, source_id, &source_name, &message), + InboundEvent::Status(status) => { + logger.line(status); + None + } + } +} + +fn handle_instant_message( + backend: &dyn BotBackend, + logger: &dyn BotLogger, + source_id: UUID, + source_name: &str, + message: &str, +) -> Option { + if source_id == backend.agent_id() { + return None; + } + logger.line(format!("[IM] {source_name}: {message}")); + let command = message.trim().to_ascii_lowercase(); + let (response, delayed) = match command.as_str() { + "help" | "?" => ( + "Commands: help, where, sit, stand, dance, fly, walk, jump, hello, hi, hey".into(), + None, + ), + "where" | "location" => ( + format!( + "I'm in {} at {}", + backend.region_name(), + backend.position().to_string() + ), + None, + ), + "sit" => action_response(backend.sit_on_ground(), "Sitting down..."), + "stand" => action_response(backend.stand(), "Standing up!"), + "dance" => action_response(backend.dance(), "Dancing!"), + "fly" => action_response(backend.fly(true), "Taking off!"), + "walk" => action_response(backend.fly(false), "Walking now."), + "jump" => match backend.jump(true) { + Ok(()) => ("Wheee!".into(), Some(DelayedAction::ReleaseJump)), + Err(error) => (format!("Error: {error}"), None), + }, + "hello" | "hi" | "hey" => (format!("Hello, {source_name}!"), None), + _ => ( + "I don't understand that command. Try 'help' for a list of commands.".into(), + None, + ), + }; + match backend.instant_message(source_id, response.clone()) { + Ok(()) => logger.line(format!("[IM] -> {source_name}: {response}")), + Err(error) => logger.line(format!("Error: {error}")), + } + delayed +} + +fn action_response( + result: Result<(), &'static str>, + success: &'static str, +) -> (String, Option) { + match result { + Ok(()) => (success.into(), None), + Err(error) => (format!("Error: {error}"), None), + } +} + +fn handle_local_chat( + backend: &dyn BotBackend, + logger: &dyn BotLogger, + source_id: UUID, + source_name: &str, + message: &str, +) -> Option { + if source_id == backend.agent_id() { + return None; + } + logger.line(format!("[Chat] {source_name}: {message}")); + let normalized = message.to_ascii_lowercase(); + (normalized.contains("hello") || normalized.contains("hi ")) + .then(|| DelayedAction::Greeting(format!("Hello, {source_name}!"))) +} + +fn run_fake_script(path: &Path, output: &mut dyn Write) -> Result<(), ProgramError> { + let backend = ScriptBackend::new()?; + let logger = BufferedLogger::default(); + logger.line(format!( + "Fake grid ready: {} at {}", + backend.region_name(), + backend.position().to_string() + )); + let events = read_script(path)?; + for event in events { + let delayed = handle_event(&backend, &logger, event); + backend.drain_calls(&logger); + match delayed { + Some(DelayedAction::ReleaseJump) => { + logger.line(format!("WAIT {}ms", JUMP_DURATION.as_millis())); + if let Err(error) = backend.jump(false) { + logger.line(format!("Error: {error}")); + } + } + Some(DelayedAction::Greeting(message)) => { + logger.line(format!("WAIT {}ms", GREETING_DELAY.as_millis())); + if let Err(error) = backend.local_chat(message) { + logger.line(format!("Error: {error}")); + } + } + None => {} + } + backend.drain_calls(&logger); + } + logger.line("Fake grid logout complete; active_tasks=0 open_sockets=0".into()); + logger.finish(output) +} + +fn read_script(path: &Path) -> Result, ProgramError> { + let file = File::open(path).map_err(|source| ProgramError::Io { + action: format!("opening fake-grid script {}", path.display()), + source, + })?; + if file.metadata().map_or(0, |metadata| metadata.len()) > MAX_SCRIPT_BYTES { + return Err(ProgramError::InvalidScript { + line: 0, + reason: "script exceeds the 1 MiB limit", + }); + } + let mut bytes = Vec::new(); + BufReader::new(file) + .take(MAX_SCRIPT_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|source| ProgramError::Io { + action: format!("reading fake-grid script {}", path.display()), + source, + })?; + if bytes.len() as u64 > MAX_SCRIPT_BYTES { + return Err(ProgramError::InvalidScript { + line: 0, + reason: "script exceeds the 1 MiB limit", + }); + } + let mut events = Vec::new(); + for (index, line) in BufReader::new(bytes.as_slice()).lines().enumerate() { + let line_number = index + 1; + let line = line.map_err(|source| ProgramError::Io { + action: format!("reading fake-grid script {}", path.display()), + source, + })?; + let line = line.trim_end_matches('\r'); + if line.is_empty() || line.starts_with('#') { + continue; + } + if events.len() >= MAX_SCRIPT_EVENTS { + return Err(ProgramError::InvalidScript { + line: line_number, + reason: "script contains more than 1024 events", + }); + } + events.push(parse_script_line(line, line_number)?); + } + Ok(events) +} + +fn parse_script_line(line: &str, line_number: usize) -> Result { + let fields = line.splitn(4, '\t').collect::>(); + match fields.as_slice() { + [kind @ ("im" | "chat"), source, name, message] => { + if name.is_empty() || name.len() > MAX_NAME_BYTES { + return Err(ProgramError::InvalidScript { + line: line_number, + reason: "source name is empty or too long", + }); + } + if message.len() > MAX_MESSAGE_BYTES { + return Err(ProgramError::InvalidScript { + line: line_number, + reason: "message is too long", + }); + } + let source_id = parse_uuid(source, line_number)?; + if *kind == "im" { + Ok(InboundEvent::InstantMessage { + source_id, + source_name: (*name).into(), + message: (*message).into(), + }) + } else { + Ok(InboundEvent::LocalChat { + source_id, + source_name: (*name).into(), + message: (*message).into(), + }) + } + } + ["status", message] if message.len() <= MAX_MESSAGE_BYTES => { + Ok(InboundEvent::Status((*message).into())) + } + _ => Err(ProgramError::InvalidScript { + line: line_number, + reason: "expected im/chatuuidnamemessage or statusmessage", + }), + } +} + +fn parse_uuid(value: &str, line: usize) -> Result { + UUID::new_with_string(value.into()).map_err(|_| ProgramError::InvalidScript { + line, + reason: "source UUID is invalid", + }) +} + +fn send_event(sender: &mpsc::Sender, event: InboundEvent, dropped: &AtomicUsize) { + if sender.try_send(event).is_err() { + dropped.fetch_add(1, Ordering::Relaxed); + } +} + +fn redact_text(value: &str) -> String { + let lowercase = value.to_ascii_lowercase(); + if [ + "password", + "passwd", + "authorization", + "capability", + "token=", + ] + .iter() + .any(|marker| lowercase.contains(marker)) + { + return "".into(); + } + value + .split_whitespace() + .map(|word| { + if word.contains("://") { + "" + } else { + word + } + }) + .collect::>() + .join(" ") +} + +fn lock(value: &Mutex) -> std::sync::MutexGuard<'_, T> { + value + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn cancellation_releases_jump_and_joins_delayed_tasks() { + let backend = Arc::new(ScriptBackend::new().expect("script backend")); + let logger = Arc::new(BufferedLogger::default()); + let cancellation = CancellationTokenSource::new(); + let (sender, receiver) = mpsc::channel(EVENT_QUEUE_CAPACITY); + let source = UUID::new_with_string("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee".into()) + .expect("source UUID"); + let worker = tokio::spawn(event_loop( + Arc::clone(&backend), + logger, + receiver, + cancellation.token(), + )); + sender + .send(InboundEvent::InstantMessage { + source_id: source, + source_name: "Test Resident".into(), + message: "jump".into(), + }) + .await + .expect("queue jump"); + for _ in 0..100 { + if lock(&backend.calls) + .iter() + .any(|call| call == "CALL jump true") + { + break; + } + tokio::task::yield_now().await; + } + cancellation.cancel(); + drop(sender); + tokio::time::timeout(Duration::from_secs(1), worker) + .await + .expect("worker stopped before timeout") + .expect("worker did not panic"); + let calls = lock(&backend.calls); + assert!(calls.iter().any(|call| call == "CALL jump true")); + assert!(calls.iter().any(|call| call == "CALL jump false")); + } + + #[test] + fn redaction_hides_credentials_capabilities_and_urls() { + assert_eq!(redact_text("password=hunter2"), ""); + assert_eq!(redact_text("token=secret"), ""); + assert_eq!( + redact_text("endpoint https://example.invalid/cap/secret ready"), + "endpoint ready" + ); + } +} diff --git a/programs/tests/simple_bot_cli.rs b/programs/tests/simple_bot_cli.rs new file mode 100644 index 0000000..dab8743 --- /dev/null +++ b/programs/tests/simple_bot_cli.rs @@ -0,0 +1,204 @@ +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; + +const SOURCE_ID: &str = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; +const BOT_ID: &str = "11111111-2222-3333-4444-555555555555"; +const DANCE1: &str = "b68a3d7c-de9e-fc87-eec8-543d787e5b0d"; +const EXIT_USAGE: i32 = 2; +const EXIT_INPUT: i32 = 3; + +static TEMP_ID: AtomicU64 = AtomicU64::new(0); + +struct TestDir(PathBuf); + +impl TestDir { + fn new(test_name: &str) -> Self { + let id = TEMP_ID.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "metacrate-simple-bot-{test_name}-{}-{id}", + std::process::id() + )); + fs::create_dir(&path).expect("create isolated test directory"); + Self(path) + } + + fn path(&self, name: &str) -> PathBuf { + self.0.join(name) + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn run(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_simple-bot")) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .expect("run simple-bot") +} + +fn utf8(bytes: &[u8]) -> &str { + std::str::from_utf8(bytes).expect("command output is UTF-8") +} + +fn path_text(path: &Path) -> &str { + path.to_str().expect("test path is UTF-8") +} + +fn assert_exit(output: &Output, expected: i32) { + assert_eq!( + output.status.code(), + Some(expected), + "stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn help_documents_upstream_arguments_and_commands() { + let output = run(&["--help"]); + assert!(output.status.success()); + let help = utf8(&output.stdout); + for marker in [ + "[FIRSTNAME]", + "[LASTNAME]", + "[PASSWORD]", + "--fake-script", + "help, ?", + "where, location", + "sit", + "stand", + "dance", + "fly", + "walk", + "jump", + "hello, hi, hey", + ] { + assert!(help.contains(marker), "help omitted {marker}:\n{help}"); + } + + let output = run(&[]); + assert_exit(&output, EXIT_USAGE); + assert!(utf8(&output.stderr).contains("Usage: simple-bot")); +} + +#[test] +fn scripted_fake_grid_covers_every_command_call_and_reply() { + let directory = TestDir::new("commands"); + let script = directory.path("conversation.tsv"); + let contents = format!( + "# deterministic fake-grid conversation\n\ + im\t{SOURCE_ID}\tAlice Resident\thelp\n\ + im\t{SOURCE_ID}\tAlice Resident\tlocation\n\ + im\t{SOURCE_ID}\tAlice Resident\tsit\n\ + im\t{SOURCE_ID}\tAlice Resident\tstand\n\ + im\t{SOURCE_ID}\tAlice Resident\tdance\n\ + im\t{SOURCE_ID}\tAlice Resident\tfly\n\ + im\t{SOURCE_ID}\tAlice Resident\twalk\n\ + im\t{SOURCE_ID}\tAlice Resident\tjump\n\ + im\t{SOURCE_ID}\tAlice Resident\thi\n\ + im\t{SOURCE_ID}\tAlice Resident\tunknown\n\ + chat\t{SOURCE_ID}\tAlice Resident\thello bot\n\ + im\t{BOT_ID}\tSimple Bot\thelp\n\ + chat\t{BOT_ID}\tSimple Bot\thello self\n\ + status\tconnected to https://example.invalid/cap/private\n" + ); + fs::write(&script, contents).expect("write conversation script"); + let output = run(&["--fake-script", path_text(&script)]); + assert!(output.status.success(), "{}", utf8(&output.stderr)); + assert!(output.stderr.is_empty()); + let output = utf8(&output.stdout); + + for expected in [ + "Fake grid ready: Scripted Test Region at <128, 64, 25>", + "Commands: help, where, sit, stand, dance, fly, walk, jump, hello, hi, hey", + "I'm in Scripted Test Region at <128, 64, 25>", + "CALL sit-on-ground", + "Sitting down...", + "CALL stand", + "Standing up!", + &format!("CALL animation-start {DANCE1} reliable=true"), + "Dancing!", + "CALL fly true", + "Taking off!", + "CALL fly false", + "Walking now.", + "CALL jump true", + "WAIT 500ms", + "CALL jump false", + "Wheee!", + "Hello, Alice Resident!", + "I don't understand that command.", + "WAIT 750ms", + "CALL chat channel=0 type=Normal Hello, Alice Resident!", + "connected to ", + "Fake grid logout complete; active_tasks=0 open_sockets=0", + ] { + assert!( + output.contains(expected), + "output omitted {expected}:\n{output}" + ); + } + assert!(!output.contains("hello self")); + assert!(!output.contains("[IM] Simple Bot")); + assert!(!output.contains("/cap/private")); +} + +#[test] +fn fake_grid_redacts_sensitive_messages_and_never_echoes_cli_passwords() { + let directory = TestDir::new("redaction"); + let script = directory.path("redaction.tsv"); + fs::write( + &script, + format!("im\t{SOURCE_ID}\tAlice Resident\ttoken=private-value\n"), + ) + .expect("write redaction script"); + let output = run(&["--fake-script", path_text(&script)]); + assert!(output.status.success()); + assert!(!utf8(&output.stdout).contains("private-value")); + assert!(utf8(&output.stdout).contains("")); + + let password = "super-secret-password"; + let output = run(&[ + "First", + "Last", + password, + "--fake-script", + path_text(&script), + ]); + assert_exit(&output, EXIT_USAGE); + assert!(!utf8(&output.stdout).contains(password)); + assert!(!utf8(&output.stderr).contains(password)); +} + +#[test] +fn malformed_or_oversized_scripts_fail_without_partial_output() { + let directory = TestDir::new("invalid"); + let malformed = directory.path("malformed.tsv"); + fs::write(&malformed, "im\tnot-a-uuid\tAlice Resident\thelp\n") + .expect("write malformed script"); + let output = run(&["--fake-script", path_text(&malformed)]); + assert_exit(&output, EXIT_INPUT); + assert!(output.stdout.is_empty()); + assert!(utf8(&output.stderr).contains("source UUID is invalid")); + + let oversized = directory.path("oversized.tsv"); + let mut file = fs::File::create(&oversized).expect("create oversized script"); + file.write_all(&vec![b'x'; 1024 * 1024 + 1]) + .expect("write oversized script"); + drop(file); + let output = run(&["--fake-script", path_text(&oversized)]); + assert_exit(&output, EXIT_INPUT); + assert!(output.stdout.is_empty()); + assert!(utf8(&output.stderr).contains("1 MiB limit")); +} diff --git a/tools/generate_api_shims.py b/tools/generate_api_shims.py index 2a9cac0..46cf8ba 100644 --- a/tools/generate_api_shims.py +++ b/tools/generate_api_shims.py @@ -622,6 +622,8 @@ NATIVE_GENERATED_TYPES = { } NATIVE_MEMBER_BODIES = { + "F:LibreMetaverse.Animations.DANCE1": + "crate::animation::dance1()", "M:LibreMetaverse.RLV.RlvCommon.TryGetAttachmentPointFromItemName(System.String,System.Nullable{LibreMetaverse.RLV.RlvAttachmentPoint}@)": "Self::native_try_get_attachment_point_from_item_name(item_name, attachment_point)", "M:LibreMetaverse.RLV.RlvRestriction.#ctor(LibreMetaverse.RLV.RlvRestrictionType,System.Guid,System.String,System.Collections.Generic.ICollection{System.Object})":