From c87644229e9296932302c351bc0f81397228b920 Mon Sep 17 00:00:00 2001 From: Raito Bezarius Date: Fri, 11 Oct 2024 11:40:37 +0200 Subject: [PATCH] feat(irc-proto/ircv3): add standard replies support Only parsing is supported here, not reserialization. Signed-off-by: Raito Bezarius --- irc-proto/src/command.rs | 33 ++++ irc-proto/src/error.rs | 8 + irc-proto/src/lib.rs | 1 + irc-proto/src/standard_reply.rs | 286 ++++++++++++++++++++++++++++++++ 4 files changed, 328 insertions(+) create mode 100644 irc-proto/src/standard_reply.rs diff --git a/irc-proto/src/command.rs b/irc-proto/src/command.rs index 8f96a8b..ace91aa 100644 --- a/irc-proto/src/command.rs +++ b/irc-proto/src/command.rs @@ -5,6 +5,7 @@ use crate::chan::ChannelExt; use crate::error::MessageParseError; use crate::mode::{ChannelMode, Mode, UserMode}; use crate::response::Response; +use crate::standard_reply::{StandardTypes, StandardCodes}; /// List of all client commands as defined in [RFC 2812](http://tools.ietf.org/html/rfc2812). This /// also includes commands from the @@ -200,6 +201,9 @@ pub enum Command { // Default option. /// An IRC response code with arguments and optional suffix. Response(Response, Vec), + /// https://ircv3.net/specs/extensions/standard-replies + /// [FAIL | WARN | NOTE] [] + StandardResponse(StandardTypes, String, StandardCodes, Vec, String), /// A raw IRC command unknown to the crate. Raw(String, Vec), } @@ -408,6 +412,10 @@ impl<'a> From<&'a Command> for String { &format!("{:03}", *resp as u16), &a.iter().map(|s| &s[..]).collect::>(), ), + Command::StandardResponse(ref r#type, ref command, ref code, ref args, ref description) => { + match r#type { + } + }, Command::Raw(ref c, ref a) => { stringify(c, &a.iter().map(|s| &s[..]).collect::>()) } @@ -958,6 +966,14 @@ impl Command { } else { raw(cmd, args) } + } else if StandardTypes::is_standard_type(cmd) { + let code = StandardCodes::from_message(args[0], args[1]); + let mut std_args: Vec = args.iter().skip(2).map(|&s| s.to_owned()).collect(); + let desc = std_args.pop().ok_or_else(|| MessageParseError::MissingDescriptionInStandardReply)?; + + Command::StandardResponse( + StandardTypes::from_str(cmd).map_err(MessageParseError::InvalidStandardReplyType)?, + args[0].to_owned(), code, std_args, desc) } else if let Ok(resp) = cmd.parse() { Command::Response(resp, args.into_iter().map(|s| s.to_owned()).collect()) } else { @@ -1128,6 +1144,7 @@ mod test { use super::Command; use super::Response; use crate::Message; + use crate::standard_reply::{StandardTypes, StandardCodes}; #[test] fn format_response() { @@ -1155,4 +1172,20 @@ mod test { cmd ); } + + #[test] + fn parse_standard_reply() { + let msg = "FAIL BOX BOXES_INVALID STACK CLOCKWISE :Given boxes are not supported".parse::().unwrap(); + + assert_eq!( + msg.command, + Command::StandardResponse(StandardTypes::Fail, "BOX".to_string(), StandardCodes::Custom("BOXES_INVALID".to_string()), vec!["STACK".to_string(), "CLOCKWISE".to_string()], "Given boxes are not supported".to_string()) + ); + + let msg = "NOTE * OPER_MESSAGE :Registering new accounts and channels has been disabled temporarily while we deal with the spam. Thanks for flying ExampleNet! -dan".parse::().unwrap(); + assert_eq!( + msg.command, + Command::StandardResponse(StandardTypes::Note, "*".to_string(), StandardCodes::Custom("OPER_MESSAGE".to_string()), vec![], "Registering new accounts and channels has been disabled temporarily while we deal with the spam. Thanks for flying ExampleNet! -dan".to_string()) + ); + } } diff --git a/irc-proto/src/error.rs b/irc-proto/src/error.rs index 43b24f7..33781ab 100644 --- a/irc-proto/src/error.rs +++ b/irc-proto/src/error.rs @@ -58,6 +58,14 @@ pub enum MessageParseError { /// The invalid subcommand. sub: String, }, + + /// The standard reply missed a description + #[error("missing description in standard reply")] + MissingDescriptionInStandardReply, + + /// Invalid standard reply type + #[error("invalid standard reply type: {}", .0)] + InvalidStandardReplyType(&'static str) } /// Errors that occur while parsing mode strings. diff --git a/irc-proto/src/lib.rs b/irc-proto/src/lib.rs index 80cc1c4..c1377cf 100644 --- a/irc-proto/src/lib.rs +++ b/irc-proto/src/lib.rs @@ -15,6 +15,7 @@ pub mod message; pub mod mode; pub mod prefix; pub mod response; +pub mod standard_reply; pub use self::caps::{Capability, NegotiationVersion}; pub use self::chan::ChannelExt; diff --git a/irc-proto/src/standard_reply.rs b/irc-proto/src/standard_reply.rs new file mode 100644 index 0000000..b515358 --- /dev/null +++ b/irc-proto/src/standard_reply.rs @@ -0,0 +1,286 @@ +use std::str::FromStr; + +/// Support for https://ircv3.net/specs/extensions/standard-replies +/// Implements the list of reply codes in the IRCv3 registry: https://ircv3.net/registry + +trait FromCode { + fn from_code(code: &str) -> Option where Self: Sized; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MultilineCodes { + MaxBytes, + MaxLines, + InvalidTarget, + Invalid +} + +impl FromCode for MultilineCodes { + fn from_code(code: &str) -> Option { + Some(match code { + "MULTILINE_MAX_BYTES" => Self::MaxBytes, + "MULTILINE_MAX_LINES" => Self::MaxLines, + "MULTILINE_INVALID_TARGET" => Self::InvalidTarget, + "MULTILINE_INVALID" => Self::Invalid, + _ => return None, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChatHistoryCodes { + InvalidParams, + InvalidTarget, + MessageError, + NeedMoreParams, + UnknownCommand +} + +impl FromCode for ChatHistoryCodes { + fn from_code(code: &str) -> Option { + Some(match code { + "INVALID_PARAMS" => Self::InvalidParams, + "INVALID_TARGET" => Self::InvalidTarget, + "MESSAGE_ERROR" => Self::MessageError, + "NEED_MORE_PARAMS" => Self::NeedMoreParams, + "UNKNOWN_COMMAND" => Self::UnknownCommand, + _ => return None, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JoinCodes { + ChannelRenamed +} + +impl FromCode for JoinCodes { + fn from_code(code: &str) -> Option { + Some(match code { + "CHANNEL_RENAMED" => Self::ChannelRenamed, + _ => return None + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NickCodes { + Reserved +} + +impl FromCode for NickCodes { + fn from_code(code: &str) -> Option { + Some(match code { + "NICKNAME_RESERVED" => Self::Reserved, + _ => return None + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RedactCodes { + InvalidTarget, + Forbidden, + WindowExpired, + UnknownMsgid, +} + +impl FromCode for RedactCodes { + fn from_code(code: &str) -> Option { + Some(match code { + "INVALID_TARGET" => Self::InvalidTarget, + "REACT_FORBIDDEN" => Self::Forbidden, + "REACT_WINDOW_EXPIRED" => Self::WindowExpired, + "UNKNOWN_MSGID" => Self::UnknownMsgid, + _ => return None + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RegisterCodes { + AccountExists, + AccountNameMustBeNick, + AlreadyAuthenticated, + BadAccountName, + CompleteConnectionRequired, + InvalidEmail, + NeedNick, + TemporarilyUnavailable, + UnacceptableEmail, + UnacceptablePassword, + WeakPassword +} + +impl FromCode for RegisterCodes { + fn from_code(code: &str) -> Option { + Some(match code { + "ACCOUNT_EXISTS" => Self::AccountExists, + "ACCOUNT_NAME_MUST_BE_NICK" => Self::AccountNameMustBeNick, + "ALREADY_AUTHENTICATED" => Self::AlreadyAuthenticated, + "BAD_ACCOUNT_NAME" => Self::BadAccountName, + "COMPLETE_CONNECTION_REQUIRED" => Self::CompleteConnectionRequired, + "INVALID_EMAIL" => Self::InvalidEmail, + "NEED_NICK" => Self::NeedNick, + "TEMPORARILY_UNAVAILABLE" => Self::TemporarilyUnavailable, + "UNACCEPTABLE_EMAIL" => Self::UnacceptableEmail, + "UNACCEPTABLE_PASSWORD" => Self::UnacceptablePassword, + "WEAK_PASSWORD" => Self::WeakPassword, + _ => return None, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RenameCodes { + ChannelNameInUse, + CannotRename +} + +impl FromCode for RenameCodes { + fn from_code(code: &str) -> Option { + Some(match code { + "CHANNEL_NAME_IN_USE" => Self::ChannelNameInUse, + "CANNOT_RENAME" => Self::CannotRename, + _ => return None, + }) + } +} + + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SetNameCodes { + CannotChangeRealname, + InvalidRealname +} + +impl FromCode for SetNameCodes { + fn from_code(code: &str) -> Option { + Some(match code { + "CANNOT_CHANGE_REALNAME" => Self::CannotChangeRealname, + "INVALID_REALNAME" => Self::InvalidRealname, + _ => return None, + }) + } +} + + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VerifyCodes { + AlreadyAuthenticated, + InvalidCode, + CompleteConnectionRequired, + TemporarilyUnavailable +} + +impl FromCode for VerifyCodes { + fn from_code(code: &str) -> Option { + Some(match code { + "ALREADY_AUTHENTICATED" => Self::AlreadyAuthenticated, + "INVALID_CODE" => Self::InvalidCode, + "COMPLETE_CONNECTION_REQUIRED" => Self::CompleteConnectionRequired, + "TEMPORARILY_UNAVAILABLE" => Self::TemporarilyUnavailable, + _ => return None, + }) + } +} + + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StandardCodes { + AccountRequired, + InvalidUtf8, + + Multiline(MultilineCodes), + ChatHistory(ChatHistoryCodes), + Join(JoinCodes), + Nick(NickCodes), + Redact(RedactCodes), + Register(RegisterCodes), + Rename(RenameCodes), + SetName(SetNameCodes), + Verify(VerifyCodes), + + Custom(String) +} + +impl StandardCodes { + fn known_from_message(command: &str, code: &str) -> Option { + Some(match command { + "BATCH" => Self::Multiline(MultilineCodes::from_code(code)?), + "CHATHISTORY" => Self::ChatHistory(ChatHistoryCodes::from_code(code)?), + "JOIN" => Self::Join(JoinCodes::from_code(code)?), + "NICK" => Self::Nick(NickCodes::from_code(code)?), + "REDACT" => Self::Redact(RedactCodes::from_code(code)?), + "REGISTER" => Self::Register(RegisterCodes::from_code(code)?), + "RENAME" => Self::Rename(RenameCodes::from_code(code)?), + "SETNAME" => Self::SetName(SetNameCodes::from_code(code)?), + "VERIFY" => Self::Verify(VerifyCodes::from_code(code)?), + _ => { + match code { + "ACCOUNT_REQUIRED" => Self::AccountRequired, + "INVALID_UTF8" => Self::InvalidUtf8, + _ => Self::Custom(code.to_string()) + } + } + }) + } + + pub fn from_message(command: &str, code: &str) -> Self { + Self::known_from_message(command, code).unwrap_or_else(|| Self::Custom(code.to_string())) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StandardTypes { + Fail, + Warn, + Note +} + +impl StandardTypes { + pub fn is_standard_type(s: &str) -> bool { + s.eq_ignore_ascii_case("FAIL") || s.eq_ignore_ascii_case("WARN") || s.eq_ignore_ascii_case("NOTE") + } +} + +impl FromStr for StandardTypes { + type Err = &'static str; + fn from_str(s: &str) -> Result { + match s.to_ascii_uppercase().as_str() { + "FAIL" => Ok(Self::Fail), + "WARN" => Ok(Self::Warn), + "NOTE" => Ok(Self::Note), + _ => Err("Unexpected standard response type, neither fail, warn or note.") + } + } +} + +#[cfg(test)] +mod test { + use super::StandardCodes; + + #[test] + fn parse_spec_example1() { + let (command, code) = ("ACC", "REG_INVALID_CALLBACK"); + assert_eq!(StandardCodes::Custom("REG_INVALID_CALLBACK".to_string()), StandardCodes::from_message(command, code)); + } + + #[test] + fn parse_spec_example2() { + let (command, code) = ("BOX", "BOXES_INVALID"); + assert_eq!(StandardCodes::Custom("BOXES_INVALID".to_string()), StandardCodes::from_message(command, code)); + } + + #[test] + fn parse_spec_example3() { + let (command, code) = ("*", "ACCOUNT_REQUIRED_TO_CONNECT"); + assert_eq!(StandardCodes::Custom("ACCOUNT_REQUIRED_TO_CONNECT".to_string()), StandardCodes::from_message(command, code)); + } + + #[test] + fn parse_batch_example() { + let (command, code) = ("BATCH", "MULTILINE_MAX_BYTES"); + assert_eq!(StandardCodes::Multiline(crate::standard_reply::MultilineCodes::MaxBytes), StandardCodes::from_message(command, code)); + } +}