use irc_proto::Message;
use tokio::sync::mpsc::UnboundedSender;
const MAX_IRC_LINE: usize = 510;
#[derive(Debug, Clone, Default)]
pub struct User {
pub nick: String,
pub user: String,
pub host: String,
}
pub struct Context {
pub(crate) tx: UnboundedSender<String>,
pub target: String,
pub is_channel: bool,
pub sender: Option<User>,
pub raw: Message,
pub bot_nick: String,
pub captures: Vec<String>,
}
fn sanitize(s: &str) -> String {
s.chars()
.filter(|&c| c != '\r' && c != '\n' && c != '\0')
.collect()
}
pub fn make_messages(header: &str, text: &str, suffix: &str) -> Vec<String> {
let overhead = header.len() + suffix.len() + 2; let available = MAX_IRC_LINE.saturating_sub(overhead);
if text.is_empty() || available == 0 {
return vec![format!("{header}{suffix}\r\n")];
}
if text.len() <= available {
return vec![format!("{header}{text}{suffix}\r\n")];
}
let mut messages = Vec::new();
let mut remaining = text;
while !remaining.is_empty() {
if remaining.len() <= available {
messages.push(format!("{header}{remaining}{suffix}\r\n"));
break;
}
let mut end = available;
while end > 0 && !remaining.is_char_boundary(end) {
end -= 1;
}
let split_at = remaining[..end]
.rfind(' ')
.filter(|&p| p > 0)
.unwrap_or(end.max(1));
messages.push(format!("{header}{}{suffix}\r\n", &remaining[..split_at]));
remaining = remaining[split_at..].trim_start_matches(' ');
}
messages
}
fn send_chunked(
tx: &UnboundedSender<String>,
header: &str,
text: &str,
suffix: &str,
) -> crate::Result {
for line in make_messages(header, text, suffix) {
tx.send(line).map_err(|e| Box::new(e) as crate::BoxError)?;
}
Ok(())
}
impl Context {
pub fn reply(&self, msg: impl std::fmt::Display) -> crate::Result {
let msg = sanitize(&msg.to_string());
if self.is_channel {
let prefix = self
.sender
.as_ref()
.map(|u| format!("{}, ", u.nick))
.unwrap_or_default();
let header = format!("PRIVMSG {} :{prefix}", self.target);
send_chunked(&self.tx, &header, &msg, "")
} else {
let to = self
.sender
.as_ref()
.map_or(self.target.as_str(), |u| u.nick.as_str());
let header = format!("PRIVMSG {to} :");
send_chunked(&self.tx, &header, &msg, "")
}
}
pub fn say(&self, msg: impl std::fmt::Display) -> crate::Result {
let msg = sanitize(&msg.to_string());
let target = if self.is_channel {
self.target.clone()
} else {
self.sender
.as_ref()
.map_or_else(|| self.target.clone(), |u| u.nick.clone())
};
let header = format!("PRIVMSG {target} :");
send_chunked(&self.tx, &header, &msg, "")
}
pub fn action(&self, msg: impl std::fmt::Display) -> crate::Result {
let msg = sanitize(&msg.to_string());
let target = if self.is_channel {
self.target.clone()
} else {
self.sender
.as_ref()
.map_or_else(|| self.target.clone(), |u| u.nick.clone())
};
let header = format!("PRIVMSG {target} :\x01ACTION ");
send_chunked(&self.tx, &header, &msg, "\x01")
}
#[must_use]
pub fn message_text(&self) -> &str {
match &self.raw.command {
irc_proto::Command::PRIVMSG(_, text) | irc_proto::Command::NOTICE(_, text) => text,
irc_proto::Command::PING(server, _) => server,
irc_proto::Command::PONG(_, Some(token)) => token,
irc_proto::Command::PONG(server, None) => server,
irc_proto::Command::JOIN(channel, _, _) => channel,
irc_proto::Command::PART(_, Some(reason)) => reason,
irc_proto::Command::PART(channel, None) => channel,
irc_proto::Command::QUIT(Some(message)) => message,
irc_proto::Command::KICK(_, _, Some(reason)) => reason,
irc_proto::Command::TOPIC(_, Some(topic)) => topic,
irc_proto::Command::TOPIC(channel, None) => channel,
irc_proto::Command::Response(_, args) => args.last().map(String::as_str).unwrap_or(""),
irc_proto::Command::Raw(_, args) => args.last().map(String::as_str).unwrap_or(""),
_ => "",
}
}
pub async fn notice(&self, msg: impl std::fmt::Display) -> crate::Result {
let msg = sanitize(&msg.to_string());
let target = if self.is_channel {
self.target.clone()
} else {
self.sender
.as_ref()
.map(|u| u.nick.clone())
.unwrap_or_else(|| self.target.clone())
};
let header = format!("NOTICE {target} :");
send_chunked(&self.tx, &header, &msg, "")
}
pub async fn whisper(&self, msg: impl std::fmt::Display) -> crate::Result {
let msg = sanitize(&msg.to_string());
let to = self
.sender
.as_ref()
.map(|u| u.nick.as_str())
.unwrap_or(self.target.as_str());
let header = format!("PRIVMSG {to} :");
send_chunked(&self.tx, &header, &msg, "")
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::sync::mpsc;
fn make_ctx(target: &str, is_channel: bool) -> (Context, mpsc::UnboundedReceiver<String>) {
let (tx, rx) = mpsc::unbounded_channel();
let raw = ":nick!u@h PRIVMSG #chan :hello"
.parse::<irc_proto::Message>()
.unwrap();
let ctx = Context {
tx,
target: target.to_string(),
is_channel,
sender: Some(User {
nick: "nick".to_string(),
user: "u".to_string(),
host: "h".to_string(),
}),
raw,
bot_nick: "bot".to_string(),
captures: vec![],
};
(ctx, rx)
}
#[test]
fn sanitize_strips_carriage_return() {
assert_eq!(sanitize("foo\rbar"), "foobar");
}
#[test]
fn sanitize_strips_newline() {
assert_eq!(sanitize("foo\nbar"), "foobar");
}
#[test]
fn sanitize_strips_null_byte() {
assert_eq!(sanitize("foo\0bar"), "foobar");
}
#[test]
fn sanitize_keeps_normal_text() {
assert_eq!(sanitize("hello world"), "hello world");
}
#[test]
fn message_text_returns_trailing_param() {
let (ctx, _rx) = make_ctx("#chan", true);
assert_eq!(ctx.message_text(), "hello");
}
#[test]
fn say_in_channel_sends_privmsg_to_channel() {
let (ctx, mut rx) = make_ctx("#chan", true);
ctx.say("hi there").unwrap();
assert_eq!(rx.try_recv().unwrap(), "PRIVMSG #chan :hi there\r\n");
}
#[test]
fn say_in_query_sends_privmsg_to_sender_nick() {
let (ctx, mut rx) = make_ctx("bot", false);
ctx.say("hi there").unwrap();
assert_eq!(rx.try_recv().unwrap(), "PRIVMSG nick :hi there\r\n");
}
#[test]
fn say_strips_injection_characters() {
let (ctx, mut rx) = make_ctx("#chan", true);
ctx.say("evil\r\nJOIN #other").unwrap();
assert_eq!(rx.try_recv().unwrap(), "PRIVMSG #chan :evilJOIN #other\r\n");
}
#[test]
fn reply_in_channel_prefixes_sender_nick() {
let (ctx, mut rx) = make_ctx("#chan", true);
ctx.reply("pong").unwrap();
assert_eq!(rx.try_recv().unwrap(), "PRIVMSG #chan :nick, pong\r\n");
}
#[test]
fn reply_in_query_sends_to_sender_nick() {
let (ctx, mut rx) = make_ctx("bot", false);
ctx.reply("pong").unwrap();
assert_eq!(rx.try_recv().unwrap(), "PRIVMSG nick :pong\r\n");
}
#[test]
fn action_in_channel_wraps_in_ctcp() {
let (ctx, mut rx) = make_ctx("#chan", true);
ctx.action("waves").unwrap();
assert_eq!(
rx.try_recv().unwrap(),
"PRIVMSG #chan :\x01ACTION waves\x01\r\n"
);
}
#[tokio::test]
async fn notice_in_channel_sends_notice_command() {
let (ctx, mut rx) = make_ctx("#chan", true);
ctx.notice("status").await.unwrap();
assert_eq!(rx.try_recv().unwrap(), "NOTICE #chan :status\r\n");
}
#[tokio::test]
async fn notice_in_query_sends_to_sender_nick() {
let (ctx, mut rx) = make_ctx("bot", false);
ctx.notice("status").await.unwrap();
assert_eq!(rx.try_recv().unwrap(), "NOTICE nick :status\r\n");
}
#[tokio::test]
async fn whisper_sends_pm_to_sender() {
let (ctx, mut rx) = make_ctx("#chan", true);
ctx.whisper("secret").await.unwrap();
assert_eq!(rx.try_recv().unwrap(), "PRIVMSG nick :secret\r\n");
}
}