#![cfg(feature = "integration")]
use std::time::Duration;
use futures::StreamExt;
use irc::client::prelude::{Client, Command, Config};
use testcontainers::{
core::{ContainerPort, WaitFor},
runners::AsyncRunner,
CopyDataSource, GenericImage, ImageExt,
};
use tokio::time::timeout;
use ircbot::{bot, Context, Result};
#[bot]
impl TestBot {
#[command("ping")]
async fn ping(&self, ctx: Context) -> Result {
ctx.reply("pong!")
}
#[command("echo")]
async fn echo(&self, ctx: Context, text: String) -> Result {
ctx.say(text)
}
}
const NGIRCD_CONF: &[u8] = b"\
[Global]
ServerGID=irc
ServerUID=irc
[Limits]
PingTimeout = 5
[Options]
Ident=no
PAM=no
[SSL]
CAFile=/etc/ssl/certs/ca-certificates.crt
";
async fn start_ngircd() -> (testcontainers::ContainerAsync<GenericImage>, u16) {
let container = GenericImage::new("ghcr.io/ngircd/ngircd", "latest")
.with_exposed_port(ContainerPort::Tcp(6667))
.with_wait_for(WaitFor::message_on_stdout("ready."))
.with_copy_to(
"/opt/ngircd/etc/ngircd.conf",
CopyDataSource::Data(NGIRCD_CONF.to_vec()),
)
.start()
.await
.expect("failed to start ngircd container");
let port = container
.get_host_port_ipv4(ContainerPort::Tcp(6667))
.await
.expect("failed to get mapped host port for ngircd");
(container, port)
}
fn irc_config(port: u16, nick: &str, channels: Vec<String>) -> Config {
Config {
nickname: Some(nick.to_owned()),
server: Some("127.0.0.1".to_owned()),
port: Some(port),
channels,
..Default::default()
}
}
async fn read_until<T>(
stream: &mut irc::client::ClientStream,
predicate: impl Fn(&irc::proto::Message) -> Option<T>,
max_wait: Duration,
) -> T {
timeout(max_wait, async {
loop {
let msg = stream
.next()
.await
.expect("stream ended unexpectedly")
.expect("stream error");
if let Some(result) = predicate(&msg) {
return result;
}
}
})
.await
.expect("timed out waiting for expected IRC message")
}
async fn wait_for_bot_in_channel(stream: &mut irc::client::ClientStream) {
read_until(
stream,
|msg| {
match &msg.command {
Command::JOIN(chan, _, _)
if chan == "#test" && msg.source_nickname() == Some("testbot") =>
{
Some(())
}
Command::Response(irc::proto::Response::RPL_NAMREPLY, args) => {
let nicks = args.last().map(String::as_str).unwrap_or("");
if nicks
.split_whitespace()
.any(|n| n.trim_start_matches(['~', '&', '@', '%', '+']) == "testbot")
{
Some(())
} else {
None
}
}
_ => None,
}
},
Duration::from_secs(10),
)
.await;
}
#[tokio::test]
async fn test_ping_command() {
let (_container, port) = start_ngircd().await;
let addr = format!("127.0.0.1:{port}");
let bot = TestBot::new("testbot", &addr, ["#test"])
.await
.expect("bot failed to connect");
let bot_task = tokio::spawn(bot.main_loop());
let mut client = Client::from_config(irc_config(port, "client", vec!["#test".into()]))
.await
.expect("test client: failed to connect");
client.identify().expect("test client: identify failed");
let mut stream = client.stream().expect("test client: stream failed");
wait_for_bot_in_channel(&mut stream).await;
client
.send_privmsg("#test", "!ping")
.expect("test client: send failed");
let response = read_until(
&mut stream,
|msg| {
if let Command::PRIVMSG(ref target, ref text) = msg.command {
if target == "#test" && text.contains("pong!") {
return Some(text.clone());
}
}
None
},
Duration::from_secs(10),
)
.await;
assert!(
response.contains("pong!"),
"expected 'pong!' in bot response, got: {response}"
);
bot_task.abort();
}
#[cfg(unix)]
fn irc_read_line(stream: &mut std::net::TcpStream) -> String {
use std::io::Read;
let mut line = Vec::new();
let mut byte = [0u8; 1];
loop {
stream
.read_exact(&mut byte)
.expect("irc_read_line: read_exact failed");
line.push(byte[0]);
if line.ends_with(b"\r\n") {
break;
}
}
String::from_utf8_lossy(&line)
.trim_end_matches(['\r', '\n'])
.to_owned()
}
#[tokio::test]
#[cfg(unix)]
async fn test_hot_reload_inherit() {
use std::io::Write;
use std::os::unix::io::IntoRawFd;
use ircbot::hot_reload::{
ENV_CHANNELS, ENV_FD, ENV_KA_INTERVAL, ENV_KA_TIMEOUT, ENV_NICK, ENV_SERVER,
};
let (_container, port) = start_ngircd().await;
let addr = format!("127.0.0.1:{port}");
let mut raw_stream = std::net::TcpStream::connect(&addr).expect("failed to connect to ngircd");
raw_stream
.set_read_timeout(Some(Duration::from_secs(10)))
.expect("set_read_timeout failed");
raw_stream
.write_all(b"NICK testbot\r\nUSER testbot 0 * :testbot\r\n")
.expect("NICK/USER write failed");
loop {
let line = irc_read_line(&mut raw_stream);
if line.starts_with("PING ") {
let token = line.strip_prefix("PING ").unwrap_or("");
raw_stream
.write_all(format!("PONG {token}\r\n").as_bytes())
.expect("PONG write failed");
}
if line.contains(" 001 ") {
break;
}
}
raw_stream
.write_all(b"JOIN #test\r\n")
.expect("JOIN write failed");
loop {
let line = irc_read_line(&mut raw_stream);
if line.starts_with("PING ") {
let token = line.strip_prefix("PING ").unwrap_or("");
raw_stream
.write_all(format!("PONG {token}\r\n").as_bytes())
.expect("PONG write failed");
}
if line.contains("JOIN") && line.contains("#test") {
break;
}
}
let raw_fd = raw_stream.into_raw_fd();
let ka_interval_ms = ircbot::DEFAULT_KEEPALIVE_INTERVAL.as_millis() as u64;
let ka_timeout_ms = ircbot::DEFAULT_KEEPALIVE_TIMEOUT.as_millis() as u64;
std::env::set_var(ENV_FD, raw_fd.to_string());
std::env::set_var(ENV_NICK, "testbot");
std::env::set_var(ENV_SERVER, &addr);
std::env::set_var(ENV_CHANNELS, "#test");
std::env::set_var(ENV_KA_INTERVAL, ka_interval_ms.to_string());
std::env::set_var(ENV_KA_TIMEOUT, ka_timeout_ms.to_string());
let bot = TestBot::new("testbot", &addr, ["#test"])
.await
.expect("hot-reload bot failed to start");
let bot_task = tokio::spawn(bot.main_loop());
let mut client = Client::from_config(irc_config(port, "client", vec!["#test".into()]))
.await
.expect("test client: failed to connect");
client.identify().expect("test client: identify failed");
let mut stream = client.stream().expect("test client: stream failed");
wait_for_bot_in_channel(&mut stream).await;
client
.send_privmsg("#test", "!ping")
.expect("test client: send_privmsg failed");
let response = read_until(
&mut stream,
|msg| {
if let Command::PRIVMSG(ref target, ref text) = msg.command {
if target == "#test" && text.contains("pong!") {
return Some(text.clone());
}
}
None
},
Duration::from_secs(10),
)
.await;
assert!(
response.contains("pong!"),
"expected 'pong!' in bot response after hot reload, got: {response}"
);
bot_task.abort();
}
#[tokio::test]
async fn test_echo_command() {
let (_container, port) = start_ngircd().await;
let addr = format!("127.0.0.1:{port}");
let bot = TestBot::new("testbot", &addr, ["#test"])
.await
.expect("bot failed to connect");
let bot_task = tokio::spawn(bot.main_loop());
let mut client = Client::from_config(irc_config(port, "client", vec!["#test".into()]))
.await
.expect("test client: failed to connect");
client.identify().expect("test client: identify failed");
let mut stream = client.stream().expect("test client: stream failed");
wait_for_bot_in_channel(&mut stream).await;
client
.send_privmsg("#test", "!echo hello world")
.expect("test client: send failed");
let response = read_until(
&mut stream,
|msg| {
if let Command::PRIVMSG(ref target, ref text) = msg.command {
if target == "#test" && text.contains("hello world") {
return Some(text.clone());
}
}
None
},
Duration::from_secs(10),
)
.await;
assert!(
response.contains("hello world"),
"expected 'hello world' in bot response, got: {response}"
);
bot_task.abort();
}