Implement SimpleBot native client (#86)
Some checks failed
Native code generation / deterministic (push) Successful in 18m9s
Imaging and meshing gate / native (push) Successful in 5m45s
JPEG 2000 feature / linux (push) Successful in 2m45s
Native Rust workspace compile / compile (push) Has been cancelled
Skia feature / linux (push) Successful in 31m33s
Some checks failed
Native code generation / deterministic (push) Successful in 18m9s
Imaging and meshing gate / native (push) Successful in 5m45s
JPEG 2000 feature / linux (push) Successful in 2m45s
Native Rust workspace compile / compile (push) Has been cancelled
Skia feature / linux (push) Successful in 31m33s
This commit is contained in:
12
Cargo.lock
generated
12
Cargo.lock
generated
@@ -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",
|
||||
|
||||
11
README.md
11
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).
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
im<TAB>source-uuid<TAB>source-name<TAB>message
|
||||
chat<TAB>source-uuid<TAB>source-name<TAB>message
|
||||
status<TAB>message
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
fn main() -> std::process::ExitCode {
|
||||
libremetaverse_programs::pending_program("SimpleBot")
|
||||
libremetaverse_programs::simple_bot::main_entry()
|
||||
}
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
|
||||
pub mod commands;
|
||||
pub mod osd_inspector;
|
||||
pub mod simple_bot;
|
||||
|
||||
pub use libremetaverse::shim::pending_program;
|
||||
|
||||
964
programs/src/simple_bot.rs
Normal file
964
programs/src/simple_bot.rs
Normal file
@@ -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<String>,
|
||||
|
||||
/// Avatar last name. May also be supplied as `GRID_LAST_NAME`.
|
||||
#[arg(value_name = "LASTNAME")]
|
||||
last_name: Option<String>,
|
||||
|
||||
/// Avatar password. May also be supplied as `GRID_PASSWORD`.
|
||||
#[arg(value_name = "PASSWORD")]
|
||||
password: Option<String>,
|
||||
|
||||
/// Run a bounded tab-separated fake-grid conversation instead of logging in.
|
||||
#[arg(long, value_name = "FILE")]
|
||||
fake_script: Option<PathBuf>,
|
||||
|
||||
/// Override the login endpoint. `GRID_LOGIN_URL` is used when this is absent.
|
||||
#[arg(long, value_name = "URL")]
|
||||
login_uri: Option<String>,
|
||||
|
||||
/// 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<String>,
|
||||
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<Vec<String>>,
|
||||
}
|
||||
|
||||
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<GridClient>,
|
||||
network: NetworkManager,
|
||||
subscriptions: Mutex<Vec<Subscription>>,
|
||||
shutdown: AtomicBool,
|
||||
dropped_events: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
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<Self>, sender: &mpsc::Sender<InboundEvent>) {
|
||||
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<T>(&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<Vec<String>>,
|
||||
agent_id: UUID,
|
||||
}
|
||||
|
||||
impl ScriptBackend {
|
||||
fn new() -> Result<Self, ProgramError> {
|
||||
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<Credentials, ProgramError> {
|
||||
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<LiveGrid>,
|
||||
sender: mpsc::Sender<InboundEvent>,
|
||||
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<B: BotBackend, L: BotLogger>(
|
||||
backend: Arc<B>,
|
||||
logger: Arc<L>,
|
||||
mut receiver: mpsc::Receiver<InboundEvent>,
|
||||
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<B: BotBackend, L: BotLogger>(
|
||||
tasks: &mut JoinSet<()>,
|
||||
backend: Arc<B>,
|
||||
logger: Arc<L>,
|
||||
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<DelayedAction> {
|
||||
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<DelayedAction> {
|
||||
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<DelayedAction>) {
|
||||
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<DelayedAction> {
|
||||
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<Vec<InboundEvent>, 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<InboundEvent, ProgramError> {
|
||||
let fields = line.splitn(4, '\t').collect::<Vec<_>>();
|
||||
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/chat<TAB>uuid<TAB>name<TAB>message or status<TAB>message",
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_uuid(value: &str, line: usize) -> Result<UUID, ProgramError> {
|
||||
UUID::new_with_string(value.into()).map_err(|_| ProgramError::InvalidScript {
|
||||
line,
|
||||
reason: "source UUID is invalid",
|
||||
})
|
||||
}
|
||||
|
||||
fn send_event(sender: &mpsc::Sender<InboundEvent>, 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 "<redacted>".into();
|
||||
}
|
||||
value
|
||||
.split_whitespace()
|
||||
.map(|word| {
|
||||
if word.contains("://") {
|
||||
"<redacted-url>"
|
||||
} else {
|
||||
word
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn lock<T>(value: &Mutex<T>) -> 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"), "<redacted>");
|
||||
assert_eq!(redact_text("token=secret"), "<redacted>");
|
||||
assert_eq!(
|
||||
redact_text("endpoint https://example.invalid/cap/secret ready"),
|
||||
"endpoint <redacted-url> ready"
|
||||
);
|
||||
}
|
||||
}
|
||||
204
programs/tests/simple_bot_cli.rs
Normal file
204
programs/tests/simple_bot_cli.rs
Normal file
@@ -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 <redacted-url>",
|
||||
"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("<redacted>"));
|
||||
|
||||
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"));
|
||||
}
|
||||
@@ -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})":
|
||||
|
||||
Reference in New Issue
Block a user