Compare commits
2 Commits
70de039610
...
585afa5f6f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
585afa5f6f
|
||
|
|
30e2d9a448
|
11
Cargo.lock
generated
11
Cargo.lock
generated
@@ -2130,6 +2130,7 @@ name = "robotnik"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"better-panic",
|
"better-panic",
|
||||||
|
"bytes",
|
||||||
"cargo-husky",
|
"cargo-husky",
|
||||||
"clap",
|
"clap",
|
||||||
"color-eyre",
|
"color-eyre",
|
||||||
@@ -2494,6 +2495,15 @@ version = "1.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-registry"
|
||||||
|
version = "1.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slab"
|
name = "slab"
|
||||||
version = "0.4.11"
|
version = "0.4.11"
|
||||||
@@ -2702,6 +2712,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"signal-hook-registry",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
|
|||||||
48
Cargo.toml
48
Cargo.toml
@@ -5,6 +5,7 @@ edition = "2024"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
better-panic = "0.3.0"
|
better-panic = "0.3.0"
|
||||||
|
bytes = "1"
|
||||||
color-eyre = "0.6.3"
|
color-eyre = "0.6.3"
|
||||||
directories = "6.0"
|
directories = "6.0"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
@@ -15,36 +16,41 @@ serde_json = "1.0"
|
|||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = "0.3"
|
tracing-subscriber = "0.3"
|
||||||
|
|
||||||
[dependencies.nix]
|
[dependencies.nix]
|
||||||
version = "0.30.1"
|
version = "0.30.1"
|
||||||
features = [ "fs" ]
|
features = ["fs"]
|
||||||
|
|
||||||
[dependencies.clap]
|
[dependencies.clap]
|
||||||
version = "4.5"
|
version = "4.5"
|
||||||
features = [ "derive" ]
|
features = ["derive"]
|
||||||
|
|
||||||
[dependencies.config]
|
[dependencies.config]
|
||||||
version = "0.15"
|
version = "0.15"
|
||||||
features = [ "toml" ]
|
features = ["toml"]
|
||||||
|
|
||||||
[dependencies.serde]
|
[dependencies.serde]
|
||||||
version = "1.0"
|
version = "1.0"
|
||||||
features = [ "derive" ]
|
features = ["derive"]
|
||||||
|
|
||||||
[dependencies.tokio]
|
[dependencies.tokio]
|
||||||
version = "1"
|
version = "1"
|
||||||
features = [ "io-util", "macros", "net", "rt-multi-thread", "sync" ]
|
features = [
|
||||||
|
"io-util",
|
||||||
|
"macros",
|
||||||
|
"net",
|
||||||
|
"process",
|
||||||
|
"rt-multi-thread",
|
||||||
|
"sync",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
rstest = "0.24"
|
rstest = "0.24"
|
||||||
tempfile = "3.13"
|
tempfile = "3.13"
|
||||||
|
|
||||||
[dev-dependencies.cargo-husky]
|
[dev-dependencies.cargo-husky]
|
||||||
version = "1"
|
version = "1"
|
||||||
features = [
|
features = ["run-cargo-check", "run-cargo-clippy"]
|
||||||
"run-cargo-check",
|
|
||||||
"run-cargo-clippy",
|
|
||||||
]
|
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use irc::client::prelude::{Client, Command, Config as IRCConfig, Message};
|
|||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tracing::{Level, event, instrument};
|
use tracing::{Level, event, instrument};
|
||||||
|
|
||||||
use crate::{Event, EventManager, LLMHandle, commands};
|
use crate::{Event, EventManager, LLMHandle, plugin};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Chat {
|
pub struct Chat {
|
||||||
@@ -50,7 +50,7 @@ pub async fn new(
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Chat {
|
impl Chat {
|
||||||
pub async fn run(&mut self, mut command_in: mpsc::Receiver<commands::Command>) -> Result<()> {
|
pub async fn run(&mut self, mut command_in: mpsc::Receiver<plugin::Plugin>) -> Result<()> {
|
||||||
self.client.identify()?;
|
self.client.identify()?;
|
||||||
|
|
||||||
let mut stream = self.client.stream()?;
|
let mut stream = self.client.stream()?;
|
||||||
@@ -69,7 +69,7 @@ impl Chat {
|
|||||||
command = command_in.recv() => {
|
command = command_in.recv() => {
|
||||||
event!(Level::INFO, "Received command {:#?}", command);
|
event!(Level::INFO, "Received command {:#?}", command);
|
||||||
match command {
|
match command {
|
||||||
Some(commands::Command::SendMessage {channel, message} ) => {
|
Some(plugin::Plugin::SendMessage {channel, message} ) => {
|
||||||
// Now to pass on the message.
|
// Now to pass on the message.
|
||||||
event!(Level::INFO, "Trying to send to channel.");
|
event!(Level::INFO, "Trying to send to channel.");
|
||||||
self.client.send_privmsg(&channel, &message).wrap_err("Couldn't send to channel")?;
|
self.client.send_privmsg(&channel, &message).wrap_err("Couldn't send to channel")?;
|
||||||
|
|||||||
183
src/command.rs
Normal file
183
src/command.rs
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
// Commands that are associated with external processes (commands).
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use color_eyre::{Result, eyre::eyre};
|
||||||
|
use tokio::{fs::try_exists, process::Command, time::timeout};
|
||||||
|
use tracing::{Level, event};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CommandDir {
|
||||||
|
command_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandDir {
|
||||||
|
pub fn new(command_path: impl AsRef<Path>) -> Self {
|
||||||
|
event!(
|
||||||
|
Level::INFO,
|
||||||
|
"CommandDir initialized with path: {:?}",
|
||||||
|
command_path.as_ref()
|
||||||
|
);
|
||||||
|
CommandDir {
|
||||||
|
command_path: command_path.as_ref().to_path_buf(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_command(&self, name: impl AsRef<Path>) -> Result<String> {
|
||||||
|
let path = self.command_path.join(name.as_ref());
|
||||||
|
|
||||||
|
event!(
|
||||||
|
Level::INFO,
|
||||||
|
"Looking for {} command.",
|
||||||
|
name.as_ref().display()
|
||||||
|
);
|
||||||
|
|
||||||
|
match try_exists(&path).await {
|
||||||
|
Ok(true) => Ok(path.to_string_lossy().to_string()),
|
||||||
|
Ok(false) => Err(eyre!(format!("{} Not found.", path.to_string_lossy()))),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_command(
|
||||||
|
&self,
|
||||||
|
command_name: impl AsRef<str>,
|
||||||
|
input: impl AsRef<str>,
|
||||||
|
) -> Result<Bytes> {
|
||||||
|
let path = self.find_command(Path::new(command_name.as_ref())).await?;
|
||||||
|
// Well it exists let's cross our fingers...
|
||||||
|
let output = Command::new(path).arg(input.as_ref()).output().await?;
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
// So far so good
|
||||||
|
Ok(Bytes::from(output.stdout))
|
||||||
|
} else {
|
||||||
|
// Whoops
|
||||||
|
Err(eyre!(format!(
|
||||||
|
"Error running {}: {}",
|
||||||
|
command_name.as_ref(),
|
||||||
|
output.status
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_command_with_timeout(
|
||||||
|
&self,
|
||||||
|
command_name: impl AsRef<str>,
|
||||||
|
input: impl AsRef<str>,
|
||||||
|
time_out: Duration,
|
||||||
|
) -> Result<Bytes> {
|
||||||
|
timeout(time_out, self.run_command(command_name, input)).await?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::{
|
||||||
|
fs::{self, Permissions},
|
||||||
|
os::unix::fs::PermissionsExt,
|
||||||
|
};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn create_test_script(dir: &Path, name: &str, script: &str) -> PathBuf {
|
||||||
|
let path = dir.join(name);
|
||||||
|
fs::write(&path, script).unwrap();
|
||||||
|
fs::set_permissions(&path, Permissions::from_mode(0o755)).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_command_dir_new() {
|
||||||
|
let dir = CommandDir::new("/some/path");
|
||||||
|
assert_eq!(dir.command_path, PathBuf::from("/some/path"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_find_command_exists() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
create_test_script(temp.path(), "test_cmd", "#!/bin/bash\necho hello");
|
||||||
|
|
||||||
|
let cmd_dir = CommandDir::new(temp.path());
|
||||||
|
let result = cmd_dir.find_command("test_cmd").await;
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(result.unwrap().contains("test_cmd"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_find_command_not_found() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let cmd_dir = CommandDir::new(temp.path());
|
||||||
|
|
||||||
|
let result = cmd_dir.find_command("nonexistent").await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().to_string().contains("Not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_run_command_success() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
create_test_script(temp.path(), "echo_cmd", "#!/bin/bash\necho \"$1\"");
|
||||||
|
|
||||||
|
let cmd_dir = CommandDir::new(temp.path());
|
||||||
|
let result = cmd_dir.run_command("echo_cmd", "hello world").await;
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let output = result.unwrap();
|
||||||
|
assert_eq!(output.as_ref(), b"hello world\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_run_command_failure() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
create_test_script(temp.path(), "fail_cmd", "#!/bin/bash\nexit 1");
|
||||||
|
|
||||||
|
let cmd_dir = CommandDir::new(temp.path());
|
||||||
|
let result = cmd_dir.run_command("fail_cmd", "input").await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().to_string().contains("Error running"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_run_command_not_found() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let cmd_dir = CommandDir::new(temp.path());
|
||||||
|
|
||||||
|
let result = cmd_dir.run_command("missing", "input").await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_run_command_with_timeout_success() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
create_test_script(temp.path(), "fast_cmd", "#!/bin/bash\necho \"$1\"");
|
||||||
|
|
||||||
|
let cmd_dir = CommandDir::new(temp.path());
|
||||||
|
let result = cmd_dir
|
||||||
|
.run_command_with_timeout("fast_cmd", "quick", Duration::from_secs(5))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_run_command_with_timeout_expires() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
create_test_script(temp.path(), "slow_cmd", "#!/bin/bash\nsleep 10\necho done");
|
||||||
|
|
||||||
|
let cmd_dir = CommandDir::new(temp.path());
|
||||||
|
let result = cmd_dir
|
||||||
|
.run_command_with_timeout("slow_cmd", "input", Duration::from_millis(100))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ use tokio::{
|
|||||||
};
|
};
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
use crate::{commands::Command, event::Event};
|
use crate::{event::Event, plugin::Plugin};
|
||||||
|
|
||||||
// Hard coding for now. Maybe make this a parameter to new.
|
// Hard coding for now. Maybe make this a parameter to new.
|
||||||
const EVENT_BUF_MAX: usize = 1000;
|
const EVENT_BUF_MAX: usize = 1000;
|
||||||
@@ -49,7 +49,7 @@ impl EventManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NB: This assumes it has exclusive control of the FIFO.
|
// NB: This assumes it has exclusive control of the FIFO.
|
||||||
pub async fn start_fifo<P>(path: &P, command_tx: mpsc::Sender<Command>) -> Result<()>
|
pub async fn start_fifo<P>(path: &P, command_tx: mpsc::Sender<Plugin>) -> Result<()>
|
||||||
where
|
where
|
||||||
P: AsRef<Path> + NixPath + ?Sized,
|
P: AsRef<Path> + NixPath + ?Sized,
|
||||||
{
|
{
|
||||||
@@ -65,7 +65,7 @@ impl EventManager {
|
|||||||
|
|
||||||
while reader.read_line(&mut line).await? > 0 {
|
while reader.read_line(&mut line).await? > 0 {
|
||||||
// Now handle the command.
|
// Now handle the command.
|
||||||
let cmd: Command = serde_json::from_str(&line)?;
|
let cmd: Plugin = serde_json::from_str(&line)?;
|
||||||
info!("Command received: {:?}.", cmd);
|
info!("Command received: {:?}.", cmd);
|
||||||
command_tx.send(cmd).await?;
|
command_tx.send(cmd).await?;
|
||||||
line.clear();
|
line.clear();
|
||||||
@@ -316,7 +316,7 @@ mod tests {
|
|||||||
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
|
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
|
||||||
|
|
||||||
// Write a command to the FIFO
|
// Write a command to the FIFO
|
||||||
let cmd = Command::SendMessage {
|
let cmd = Plugin::SendMessage {
|
||||||
channel: "#test".to_string(),
|
channel: "#test".to_string(),
|
||||||
message: "hello".to_string(),
|
message: "hello".to_string(),
|
||||||
};
|
};
|
||||||
@@ -338,7 +338,7 @@ mod tests {
|
|||||||
.expect("channel closed");
|
.expect("channel closed");
|
||||||
|
|
||||||
match received {
|
match received {
|
||||||
Command::SendMessage { channel, message } => {
|
Plugin::SendMessage { channel, message } => {
|
||||||
assert_eq!(channel, "#test");
|
assert_eq!(channel, "#test");
|
||||||
assert_eq!(message, "hello");
|
assert_eq!(message, "hello");
|
||||||
}
|
}
|
||||||
@@ -362,15 +362,15 @@ mod tests {
|
|||||||
|
|
||||||
// Write multiple commands
|
// Write multiple commands
|
||||||
let commands = vec![
|
let commands = vec![
|
||||||
Command::SendMessage {
|
Plugin::SendMessage {
|
||||||
channel: "#chan1".to_string(),
|
channel: "#chan1".to_string(),
|
||||||
message: "first".to_string(),
|
message: "first".to_string(),
|
||||||
},
|
},
|
||||||
Command::SendMessage {
|
Plugin::SendMessage {
|
||||||
channel: "#chan2".to_string(),
|
channel: "#chan2".to_string(),
|
||||||
message: "second".to_string(),
|
message: "second".to_string(),
|
||||||
},
|
},
|
||||||
Command::SendMessage {
|
Plugin::SendMessage {
|
||||||
channel: "#chan3".to_string(),
|
channel: "#chan3".to_string(),
|
||||||
message: "third".to_string(),
|
message: "third".to_string(),
|
||||||
},
|
},
|
||||||
@@ -395,7 +395,7 @@ mod tests {
|
|||||||
.expect("channel closed");
|
.expect("channel closed");
|
||||||
|
|
||||||
match first {
|
match first {
|
||||||
Command::SendMessage { channel, message } => {
|
Plugin::SendMessage { channel, message } => {
|
||||||
assert_eq!(channel, "#chan1");
|
assert_eq!(channel, "#chan1");
|
||||||
assert_eq!(message, "first");
|
assert_eq!(message, "first");
|
||||||
}
|
}
|
||||||
@@ -407,7 +407,7 @@ mod tests {
|
|||||||
.expect("channel closed");
|
.expect("channel closed");
|
||||||
|
|
||||||
match second {
|
match second {
|
||||||
Command::SendMessage { channel, message } => {
|
Plugin::SendMessage { channel, message } => {
|
||||||
assert_eq!(channel, "#chan2");
|
assert_eq!(channel, "#chan2");
|
||||||
assert_eq!(message, "second");
|
assert_eq!(message, "second");
|
||||||
}
|
}
|
||||||
@@ -419,7 +419,7 @@ mod tests {
|
|||||||
.expect("channel closed");
|
.expect("channel closed");
|
||||||
|
|
||||||
match third {
|
match third {
|
||||||
Command::SendMessage { channel, message } => {
|
Plugin::SendMessage { channel, message } => {
|
||||||
assert_eq!(channel, "#chan3");
|
assert_eq!(channel, "#chan3");
|
||||||
assert_eq!(message, "third");
|
assert_eq!(message, "third");
|
||||||
}
|
}
|
||||||
@@ -449,7 +449,7 @@ mod tests {
|
|||||||
let tx = pipe::OpenOptions::new().open_sender(&path).unwrap();
|
let tx = pipe::OpenOptions::new().open_sender(&path).unwrap();
|
||||||
let mut tx = tokio::io::BufWriter::new(tx);
|
let mut tx = tokio::io::BufWriter::new(tx);
|
||||||
|
|
||||||
let cmd = Command::SendMessage {
|
let cmd = Plugin::SendMessage {
|
||||||
channel: "#first".to_string(),
|
channel: "#first".to_string(),
|
||||||
message: "batch1".to_string(),
|
message: "batch1".to_string(),
|
||||||
};
|
};
|
||||||
@@ -465,7 +465,7 @@ mod tests {
|
|||||||
.expect("channel closed");
|
.expect("channel closed");
|
||||||
|
|
||||||
match first {
|
match first {
|
||||||
Command::SendMessage { channel, message } => {
|
Plugin::SendMessage { channel, message } => {
|
||||||
assert_eq!(channel, "#first");
|
assert_eq!(channel, "#first");
|
||||||
assert_eq!(message, "batch1");
|
assert_eq!(message, "batch1");
|
||||||
}
|
}
|
||||||
@@ -482,7 +482,7 @@ mod tests {
|
|||||||
let tx = pipe::OpenOptions::new().open_sender(&fifo_path).unwrap();
|
let tx = pipe::OpenOptions::new().open_sender(&fifo_path).unwrap();
|
||||||
let mut tx = tokio::io::BufWriter::new(tx);
|
let mut tx = tokio::io::BufWriter::new(tx);
|
||||||
|
|
||||||
let cmd = Command::SendMessage {
|
let cmd = Plugin::SendMessage {
|
||||||
channel: "#second".to_string(),
|
channel: "#second".to_string(),
|
||||||
message: "batch2".to_string(),
|
message: "batch2".to_string(),
|
||||||
};
|
};
|
||||||
@@ -497,7 +497,7 @@ mod tests {
|
|||||||
.expect("channel closed");
|
.expect("channel closed");
|
||||||
|
|
||||||
match second {
|
match second {
|
||||||
Command::SendMessage { channel, message } => {
|
Plugin::SendMessage { channel, message } => {
|
||||||
assert_eq!(channel, "#second");
|
assert_eq!(channel, "#second");
|
||||||
assert_eq!(message, "batch2");
|
assert_eq!(message, "batch2");
|
||||||
}
|
}
|
||||||
@@ -524,7 +524,7 @@ mod tests {
|
|||||||
let tx = pipe::OpenOptions::new().open_sender(&fifo_path).unwrap();
|
let tx = pipe::OpenOptions::new().open_sender(&fifo_path).unwrap();
|
||||||
let mut tx = tokio::io::BufWriter::new(tx);
|
let mut tx = tokio::io::BufWriter::new(tx);
|
||||||
|
|
||||||
let cmd1 = Command::SendMessage {
|
let cmd1 = Plugin::SendMessage {
|
||||||
channel: "#test".to_string(),
|
channel: "#test".to_string(),
|
||||||
message: "first".to_string(),
|
message: "first".to_string(),
|
||||||
};
|
};
|
||||||
@@ -537,7 +537,7 @@ mod tests {
|
|||||||
// Write whitespace line
|
// Write whitespace line
|
||||||
tx.write_all(b" \n").await.unwrap();
|
tx.write_all(b" \n").await.unwrap();
|
||||||
|
|
||||||
let cmd2 = Command::SendMessage {
|
let cmd2 = Plugin::SendMessage {
|
||||||
channel: "#test".to_string(),
|
channel: "#test".to_string(),
|
||||||
message: "second".to_string(),
|
message: "second".to_string(),
|
||||||
};
|
};
|
||||||
@@ -553,7 +553,7 @@ mod tests {
|
|||||||
.expect("channel closed");
|
.expect("channel closed");
|
||||||
|
|
||||||
match first {
|
match first {
|
||||||
Command::SendMessage { channel, message } => {
|
Plugin::SendMessage { channel, message } => {
|
||||||
assert_eq!(channel, "#test");
|
assert_eq!(channel, "#test");
|
||||||
assert_eq!(message, "first");
|
assert_eq!(message, "first");
|
||||||
}
|
}
|
||||||
|
|||||||
26
src/ipc.rs
26
src/ipc.rs
@@ -1,26 +0,0 @@
|
|||||||
// Provides an IPC socket to communicate with other processes.
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use color_eyre::Result;
|
|
||||||
use tokio::net::UnixListener;
|
|
||||||
|
|
||||||
pub struct IPC {
|
|
||||||
listener: UnixListener,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IPC {
|
|
||||||
pub fn new(path: impl AsRef<Path>) -> Result<Self> {
|
|
||||||
let listener = UnixListener::bind(path)?;
|
|
||||||
Ok(Self { listener })
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run(&self) -> Result<()> {
|
|
||||||
loop {
|
|
||||||
match self.listener.accept().await {
|
|
||||||
Ok((_stream, _addr)) => {}
|
|
||||||
Err(e) => return Err(e.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,10 +9,10 @@ use tracing::{Level, info};
|
|||||||
use tracing_subscriber::FmtSubscriber;
|
use tracing_subscriber::FmtSubscriber;
|
||||||
|
|
||||||
pub mod chat;
|
pub mod chat;
|
||||||
pub mod commands;
|
pub mod command;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod event_manager;
|
pub mod event_manager;
|
||||||
pub mod ipc;
|
pub mod plugin;
|
||||||
pub mod qna;
|
pub mod qna;
|
||||||
pub mod setup;
|
pub mod setup;
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ use std::fmt::Display;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
pub enum Command {
|
pub enum Plugin {
|
||||||
SendMessage { channel: String, message: String },
|
SendMessage { channel: String, message: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for Command {
|
impl Display for Plugin {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::SendMessage { channel, message } => {
|
Self::SendMessage { channel, message } => {
|
||||||
290
tests/command_test.rs
Normal file
290
tests/command_test.rs
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
use std::{
|
||||||
|
fs::{self, Permissions},
|
||||||
|
os::unix::fs::PermissionsExt,
|
||||||
|
path::Path,
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use robotnik::command::CommandDir;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
/// Helper to create executable test scripts
|
||||||
|
fn create_command(dir: &Path, name: &str, script: &str) {
|
||||||
|
let path = dir.join(name);
|
||||||
|
fs::write(&path, script).unwrap();
|
||||||
|
fs::set_permissions(&path, Permissions::from_mode(0o755)).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a bot message like "!weather 73135" into (command_name, argument)
|
||||||
|
fn parse_bot_message(message: &str) -> Option<(&str, &str)> {
|
||||||
|
if !message.starts_with('!') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let without_prefix = &message[1..];
|
||||||
|
let mut parts = without_prefix.splitn(2, ' ');
|
||||||
|
let command = parts.next()?;
|
||||||
|
let arg = parts.next().unwrap_or("");
|
||||||
|
Some((command, arg))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_bot_message_finds_and_runs_command() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
// Create a weather command that echoes the zip code
|
||||||
|
create_command(
|
||||||
|
temp.path(),
|
||||||
|
"weather",
|
||||||
|
r#"#!/bin/bash
|
||||||
|
echo "Weather for $1: Sunny, 72°F"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let cmd_dir = CommandDir::new(temp.path());
|
||||||
|
let message = "!weather 73135";
|
||||||
|
|
||||||
|
// Parse the message
|
||||||
|
let (command_name, arg) = parse_bot_message(message).unwrap();
|
||||||
|
assert_eq!(command_name, "weather");
|
||||||
|
assert_eq!(arg, "73135");
|
||||||
|
|
||||||
|
// Find and run the command
|
||||||
|
let result = cmd_dir.run_command(command_name, arg).await;
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let bytes = result.unwrap();
|
||||||
|
let output = String::from_utf8_lossy(&bytes);
|
||||||
|
assert!(output.contains("Weather for 73135"));
|
||||||
|
assert!(output.contains("Sunny"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_bot_message_command_not_found() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let cmd_dir = CommandDir::new(temp.path());
|
||||||
|
|
||||||
|
let message = "!nonexistent arg";
|
||||||
|
let (command_name, arg) = parse_bot_message(message).unwrap();
|
||||||
|
|
||||||
|
let result = cmd_dir.run_command(command_name, arg).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().to_string().contains("Not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_bot_message_with_multiple_arguments() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
// Create a command that handles multiple words as a single argument
|
||||||
|
create_command(
|
||||||
|
temp.path(),
|
||||||
|
"echo",
|
||||||
|
r#"#!/bin/bash
|
||||||
|
echo "You said: $1"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let cmd_dir = CommandDir::new(temp.path());
|
||||||
|
let message = "!echo hello world how are you";
|
||||||
|
|
||||||
|
let (command_name, arg) = parse_bot_message(message).unwrap();
|
||||||
|
assert_eq!(command_name, "echo");
|
||||||
|
assert_eq!(arg, "hello world how are you");
|
||||||
|
|
||||||
|
let result = cmd_dir.run_command(command_name, arg).await;
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let bytes = result.unwrap();
|
||||||
|
let output = String::from_utf8_lossy(&bytes);
|
||||||
|
assert!(output.contains("hello world how are you"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_bot_message_without_argument() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
create_command(
|
||||||
|
temp.path(),
|
||||||
|
"help",
|
||||||
|
r#"#!/bin/bash
|
||||||
|
echo "Available commands: weather, echo, help"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let cmd_dir = CommandDir::new(temp.path());
|
||||||
|
let message = "!help";
|
||||||
|
|
||||||
|
let (command_name, arg) = parse_bot_message(message).unwrap();
|
||||||
|
assert_eq!(command_name, "help");
|
||||||
|
assert_eq!(arg, "");
|
||||||
|
|
||||||
|
let result = cmd_dir.run_command(command_name, arg).await;
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let bytes = result.unwrap();
|
||||||
|
let output = String::from_utf8_lossy(&bytes);
|
||||||
|
assert!(output.contains("Available commands"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_bot_message_command_returns_error_exit_code() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
// Create a command that fails for invalid input
|
||||||
|
create_command(
|
||||||
|
temp.path(),
|
||||||
|
"validate",
|
||||||
|
r#"#!/bin/bash
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
echo "Error: Input required" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Valid: $1"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let cmd_dir = CommandDir::new(temp.path());
|
||||||
|
let message = "!validate";
|
||||||
|
|
||||||
|
let (command_name, arg) = parse_bot_message(message).unwrap();
|
||||||
|
let result = cmd_dir.run_command(command_name, arg).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().to_string().contains("Error running"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_bot_message_with_timeout() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
create_command(
|
||||||
|
temp.path(),
|
||||||
|
"quick",
|
||||||
|
r#"#!/bin/bash
|
||||||
|
echo "Result: $1"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let cmd_dir = CommandDir::new(temp.path());
|
||||||
|
let message = "!quick test";
|
||||||
|
|
||||||
|
let (command_name, arg) = parse_bot_message(message).unwrap();
|
||||||
|
let result = cmd_dir
|
||||||
|
.run_command_with_timeout(command_name, arg, Duration::from_secs(5))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_bot_message_command_times_out() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
create_command(
|
||||||
|
temp.path(),
|
||||||
|
"slow",
|
||||||
|
r#"#!/bin/bash
|
||||||
|
sleep 10
|
||||||
|
echo "Done"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let cmd_dir = CommandDir::new(temp.path());
|
||||||
|
let message = "!slow arg";
|
||||||
|
|
||||||
|
let (command_name, arg) = parse_bot_message(message).unwrap();
|
||||||
|
let result = cmd_dir
|
||||||
|
.run_command_with_timeout(command_name, arg, Duration::from_millis(100))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_multiple_commands_in_directory() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
create_command(
|
||||||
|
temp.path(),
|
||||||
|
"weather",
|
||||||
|
r#"#!/bin/bash
|
||||||
|
echo "Weather: Sunny"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
create_command(
|
||||||
|
temp.path(),
|
||||||
|
"time",
|
||||||
|
r#"#!/bin/bash
|
||||||
|
echo "Time: 12:00"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
create_command(
|
||||||
|
temp.path(),
|
||||||
|
"joke",
|
||||||
|
r#"#!/bin/bash
|
||||||
|
echo "Why did the robot go on vacation? To recharge!"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let cmd_dir = CommandDir::new(temp.path());
|
||||||
|
|
||||||
|
// Test each command
|
||||||
|
let messages = ["!weather", "!time", "!joke"];
|
||||||
|
let expected = ["Sunny", "12:00", "recharge"];
|
||||||
|
|
||||||
|
for (message, expect) in messages.iter().zip(expected.iter()) {
|
||||||
|
let (command_name, arg) = parse_bot_message(message).unwrap();
|
||||||
|
let result = cmd_dir.run_command(command_name, arg).await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let bytes = result.unwrap();
|
||||||
|
let output = String::from_utf8_lossy(&bytes);
|
||||||
|
assert!(
|
||||||
|
output.contains(expect),
|
||||||
|
"Expected '{}' in '{}'",
|
||||||
|
expect,
|
||||||
|
output
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_non_bot_message_ignored() {
|
||||||
|
// Messages not starting with ! should be ignored
|
||||||
|
let messages = ["hello world", "weather 73135", "?help", "/command", ""];
|
||||||
|
|
||||||
|
for message in messages {
|
||||||
|
assert!(
|
||||||
|
parse_bot_message(message).is_none(),
|
||||||
|
"Should ignore: {}",
|
||||||
|
message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_command_output_is_bytes() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
// Create a command that outputs binary-safe content
|
||||||
|
create_command(
|
||||||
|
temp.path(),
|
||||||
|
"binary",
|
||||||
|
r#"#!/bin/bash
|
||||||
|
printf "Hello\x00World"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let cmd_dir = CommandDir::new(temp.path());
|
||||||
|
let message = "!binary test";
|
||||||
|
|
||||||
|
let (command_name, arg) = parse_bot_message(message).unwrap();
|
||||||
|
let result = cmd_dir.run_command(command_name, arg).await;
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let output = result.unwrap();
|
||||||
|
// Should preserve the null byte
|
||||||
|
assert_eq!(&output[..], b"Hello\x00World");
|
||||||
|
}
|
||||||
@@ -486,7 +486,7 @@ async fn test_json_deserialization_of_received_events() {
|
|||||||
reader.read_line(&mut line).await.unwrap();
|
reader.read_line(&mut line).await.unwrap();
|
||||||
|
|
||||||
// Should be valid JSON
|
// Should be valid JSON
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&line.trim()).unwrap();
|
let parsed: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
|
||||||
|
|
||||||
assert_eq!(parsed["message"], test_message);
|
assert_eq!(parsed["message"], test_message);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user