diff --git a/doc/command-handlers.md b/doc/command-handlers.md index a172d691..edcd09d4 100644 --- a/doc/command-handlers.md +++ b/doc/command-handlers.md @@ -14,17 +14,23 @@ Handlers are identified by the `command_handler` attribute macro. ## The command_handler macro -The `command_handler` attribute macro has two possible forms, allowing for -multiple command dispatchers to exist. +The `command_handler` attribute macro can take several arguments: -The single-argument form - e.g. `#[command_handler("CAP")]` defines a handler -for the 'default' global dispatcher, which is used to look up client protocol -commands. +```rs +#[command_handler("PRIMARY", "ALIAS", "ALIAS2", in("PARENT"), restricted)] +``` -The two-argument form - e.g. `#[command_handler("CERT", in("NS"))]` puts the -handler into a named secondary dispatcher, in this case `"NS"`. This form is +The first argument defines the primary name of the command. Any further strings +given will be used as aliases for the command. + +If an argument is given of the form `in("PARENT")`, the command handler will be +put into a named secondary dispatcher, in this case `"PARENT"`. This form is used to define handlers for services commands, and may have other uses in the -future. +future. If this argument is not given, the handler is added to the 'default' +global dispatcher, which is used to look up client protocol commands. + +If the `restricted` keyword is added, the command will be marked as for operators +and will not be shown in `HELP` output to users. ## Async handlers diff --git a/sable_ircd/src/command/dispatcher.rs b/sable_ircd/src/command/dispatcher.rs index 67b0bc5e..6553e89a 100644 --- a/sable_ircd/src/command/dispatcher.rs +++ b/sable_ircd/src/command/dispatcher.rs @@ -1,3 +1,5 @@ +use std::collections::hash_map; + use super::{plumbing::Command, *}; /// Type alias for a boxed command context @@ -7,17 +9,21 @@ pub type BoxCommand<'cmd> = Box; /// attribute macro pub type CommandHandlerWrapper = for<'a> fn(BoxCommand<'a>) -> Option>; +#[derive(Clone)] /// A command handler registration. Constructed by the `command_handler` attribute macro. pub struct CommandRegistration { pub(super) command: &'static str, + pub(super) aliases: &'static [&'static str], pub(super) dispatcher: Option<&'static str>, pub(super) handler: CommandHandlerWrapper, + pub(super) restricted: bool, + pub(super) docs: &'static [&'static str], } /// A command dispatcher. Collects registered command handlers and allows lookup by /// command name. pub struct CommandDispatcher { - handlers: HashMap, + commands: HashMap, } inventory::collect!(CommandRegistration); @@ -42,11 +48,14 @@ impl CommandDispatcher { for reg in inventory::iter:: { if reg.dispatcher == category_name { - map.insert(reg.command.to_ascii_uppercase(), reg.handler); + map.insert(reg.command.to_ascii_uppercase(), reg.clone()); + for alias in reg.aliases { + map.insert(alias.to_ascii_uppercase(), reg.clone()); + } } } - Self { handlers: map } + Self { commands: map } } /// Look up and execute the handler function for to a given command. @@ -59,12 +68,20 @@ impl CommandDispatcher { ) -> Option> { let command: BoxCommand<'cmd> = Box::new(command); - match self.handlers.get(&command.command().to_ascii_uppercase()) { - Some(handler) => handler(command), + match self.commands.get(&command.command().to_ascii_uppercase()) { + Some(cmd) => (cmd.handler)(command), None => { command.notify_error(CommandError::CommandNotFound(command.command().to_owned())); None } } } + + pub fn get_command(&self, command: &str) -> Option<&CommandRegistration> { + self.commands.get(&command.to_ascii_uppercase()) + } + + pub fn iter_commands(&self) -> hash_map::Iter<'_, String, CommandRegistration> { + self.commands.iter() + } } diff --git a/sable_ircd/src/command/handlers/help.rs b/sable_ircd/src/command/handlers/help.rs new file mode 100644 index 00000000..45d25af7 --- /dev/null +++ b/sable_ircd/src/command/handlers/help.rs @@ -0,0 +1,74 @@ +use super::*; + +use itertools::Itertools; + +#[command_handler("HELP", "UHELP")] +/// HELP [] +/// +/// HELP displays information for topic requested. +/// If no topic is requested, it will list available +/// help topics. +fn help_handler( + command: &dyn Command, + response: &dyn CommandResponse, + server: &ClientServer, + source: UserSource, + topic: Option<&str>, +) -> CommandResult { + // TODO: oper help? (and if oper help is on the same command, UHELP like solanum?) + // TODO: non-command help topics + let is_oper = command.command().to_ascii_uppercase() != "UHELP" && source.is_oper(); + + match topic { + Some(s) => { + let topic = s.to_ascii_uppercase(); + let topic = topic + .split_once(' ') + .map_or(topic.clone(), |(t, _)| t.to_string()); + + if let Some(cmd) = server.get_command(&topic) { + if cmd.docs.len() > 0 { + // TODO + if cmd.restricted && is_oper { + response.numeric(make_numeric!(HelpNotFound, &topic)); + return Ok(()); + } + let mut lines = cmd.docs.iter(); + response.numeric(make_numeric!( + HelpStart, + &topic, + lines.next().unwrap_or(&topic.as_str()) + )); + for line in lines { + response.numeric(make_numeric!(HelpText, &topic, line)); + } + response.numeric(make_numeric!(EndOfHelp, &topic)); + return Ok(()); + } + } + response.numeric(make_numeric!(HelpNotFound, &topic)); + } + None => { + let topic = "*"; + response.numeric(make_numeric!(HelpStart, topic, "Available help topics:")); + response.numeric(make_numeric!(HelpText, topic, "")); + for chunk in &server + .iter_commands() + .filter_map(|(k, v)| { + if !v.restricted || is_oper { + Some(k.to_ascii_uppercase()) + } else { + None + } + }) + .sorted() + .chunks(4) + { + let line = format!("{:16}", chunk.format(" ")); + response.numeric(make_numeric!(HelpText, topic, &line)); + } + response.numeric(make_numeric!(EndOfHelp, topic)); + } + }; + Ok(()) +} diff --git a/sable_ircd/src/command/mod.rs b/sable_ircd/src/command/mod.rs index ea9a18ae..269c749c 100644 --- a/sable_ircd/src/command/mod.rs +++ b/sable_ircd/src/command/mod.rs @@ -42,6 +42,7 @@ mod handlers { mod ban; mod cap; mod chathistory; + mod help; mod info; mod invite; mod join; diff --git a/sable_ircd/src/messages/numeric.rs b/sable_ircd/src/messages/numeric.rs index 4b3b9ac9..d64fac84 100644 --- a/sable_ircd/src/messages/numeric.rs +++ b/sable_ircd/src/messages/numeric.rs @@ -70,6 +70,10 @@ define_messages! { 371(Info) => { (line: &str) => ":{line}" }, 374(EndOfInfo) => { () => ":End of /INFO list" }, + 704(HelpStart) => { (subj: &str, line: &str) => "{subj} :{line}" }, + 705(HelpText) => { (subj: &str, line: &str) => "{subj} :{line}" }, + 706(EndOfHelp) => { (subj: &str) => "{subj} :End of /HELP" }, + 401(NoSuchTarget) => { (unknown: &str) => "{unknown} :No such nick/channel" }, 402(NoSuchServer) => { (server_name: &ServerName) => "{server_name} :No such server" }, @@ -125,6 +129,8 @@ define_messages! { 440(ServicesNotAvailable) => { () => ":Services are not available"}, + 524(HelpNotFound) => { (subj: &str) => "{subj} :No help available on this topic" }, + // https://ircv3.net/specs/extensions/monitor 730(MonOnline) => { (content: &str ) => ":{content}" }, 731(MonOffline) => { (content: &str ) => ":{content}" }, diff --git a/sable_ircd/src/server/mod.rs b/sable_ircd/src/server/mod.rs index 1c983dd1..c64451b3 100644 --- a/sable_ircd/src/server/mod.rs +++ b/sable_ircd/src/server/mod.rs @@ -21,7 +21,7 @@ use tokio::{ }; use std::{ - collections::VecDeque, + collections::{hash_map, VecDeque}, sync::{Arc, Weak}, time::Duration, }; @@ -116,6 +116,16 @@ impl ClientServer { self.node.name() } + /// Get a command from the server's dispatcher + pub fn get_command(&self, cmd: &str) -> Option<&CommandRegistration> { + self.command_dispatcher.get_command(cmd) + } + + /// Get a command from the server's dispatcher + pub fn iter_commands(&self) -> hash_map::Iter<'_, String, CommandRegistration> { + self.command_dispatcher.iter_commands() + } + /// Submit a command action to process in the next loop iteration. #[tracing::instrument(skip(self))] pub fn add_action(&self, act: CommandAction) { diff --git a/sable_macros/src/command_handler.rs b/sable_macros/src/command_handler.rs index 373026d9..0c014091 100644 --- a/sable_macros/src/command_handler.rs +++ b/sable_macros/src/command_handler.rs @@ -2,32 +2,67 @@ use super::*; use quote::{quote, quote_spanned}; use syn::{ - parenthesized, parse::Parse, parse_macro_input, token::In, Ident, ItemFn, LitStr, Token, + parenthesized, parse::Parse, parse_macro_input, token::In, Attribute, Ident, ItemFn, LitStr, + Meta, MetaNameValue, Token, }; struct CommandHandlerAttr { command_name: LitStr, + aliases: Vec, dispatcher: Option, + restricted: bool, } impl Parse for CommandHandlerAttr { fn parse(input: syn::parse::ParseStream) -> syn::Result { let command_name = input.parse()?; - let dispatcher = if input.parse::().is_ok() { - let content; - input.parse::()?; - let _paren = parenthesized!(content in input); - Some(content.parse()?) - } else { - None - }; + let mut aliases = vec![]; + let mut dispatcher = None; + let mut restricted = false; + while input.peek(Token![,]) { + if !input.peek2(LitStr) { + break; + } + let _ = input.parse::(); + aliases.push(input.parse()?); + } + while input.peek(Token![,]) { + let _ = input.parse::()?; + if input.peek(In) { + let content; + input.parse::()?; + let _paren = parenthesized!(content in input); + dispatcher = Some(content.parse()?); + } else if input.peek(Ident) { + if input.parse::()? == "restricted" { + restricted = true; + } + } + } Ok(Self { command_name, + aliases, dispatcher, + restricted, }) } } +pub fn command_docs(attrs: &[Attribute]) -> Vec { + attrs + .iter() + .filter(|a| a.path.is_ident("doc")) + .filter_map(|a| match a.parse_meta() { + Ok(Meta::NameValue(MetaNameValue { + lit: syn::Lit::Str(s), + .. + })) => Some(s.value()), + _ => None, + }) + .map(|s| s.strip_prefix(' ').unwrap_or(&s).trim_end().to_owned()) + .collect() +} + pub fn command_handler(attr: TokenStream, item: TokenStream) -> TokenStream { let input = parse_macro_input!(attr as CommandHandlerAttr); let item = parse_macro_input!(item as ItemFn); @@ -43,11 +78,24 @@ pub fn command_handler(attr: TokenStream, item: TokenStream) -> TokenStream { } } + let aliases = input.aliases; + for alias in &aliases { + for c in alias.value().chars() { + if !c.is_ascii_uppercase() { + return quote_spanned!(command_name.span()=> compile_error!("Command aliases should be uppercase")).into(); + } + } + } + let dispatcher = match input.dispatcher { Some(name) => quote!( Some( #name ) ), None => quote!(None), }; + let restricted = input.restricted; + + let docs = command_docs(&item.attrs); + let body = if asyncness.is_none() { quote!( if let Err(e) = crate::command::plumbing::call_handler(ctx.as_ref(), &super::#name, ctx.args()) @@ -90,8 +138,11 @@ pub fn command_handler(attr: TokenStream, item: TokenStream) -> TokenStream { inventory::submit!(crate::command::CommandRegistration { command: #command_name, + aliases: &[ #(#aliases),* ], dispatcher: #dispatcher, - handler: call_proxy + handler: call_proxy, + restricted: #restricted, + docs: &[ #(#docs),* ], }); } ).into()